diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3035588 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +npm-debug.log* +*.log +*.db +*.db-* +*.sqlite +*.sqlite3 +backups +operations +logs +uploads +runtime +data +local-ai/comfyui +local-ai/downloads +local-ai/.env.local-flux +MockupDesign +*.zip +FILE_CHECKSUMS_SHA256.txt +release-output +.vendoo-github-state.json +operations/first-admin-code.txt diff --git a/.env.example b/.env.example index 4125640..22ab1fe 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,80 @@ -NODE_ENV=development -HOST=127.0.0.1 -PORT=8124 -PUBLIC_BASE_URL=http://localhost:8124 -TRUST_PROXY=0 -COOKIE_SECURE=false -COOKIE_SAMESITE=lax -ALLOWED_ORIGINS=http://localhost:8124 +# Vendoo 1.36.0 – Beispielkonfiguration für Windows, Docker und NAS +# Diese Datei enthält ausschließlich Platzhalter. Für den Betrieb als .env kopieren. +# Niemals eine produktive .env in Release-ZIPs, Git oder Support-Logs aufnehmen. -DATA_DIR=./data -DB_PATH=./db/vendoo.db -UPLOAD_DIR=./uploads -BACKUP_DIR=./backups - -SESSION_SECRET= ANTHROPIC_API_KEY= OPENAI_API_KEY= OPENROUTER_API_KEY= OLLAMA_URL=http://localhost:11434 - -SMTP_HOST= -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM= +LOCAL_IMAGE_ENABLED=false +LOCAL_IMAGE_URL=http://127.0.0.1:8188 +LOCAL_IMAGE_TOKEN= +LOCAL_IMAGE_WORKFLOW_PATH=local-ai/workflows/vendoo_flux_schnell_api.json +LOCAL_IMAGE_INSTALL_PATH=local-ai/comfyui +LOCAL_IMAGE_REQUIRE_TOKEN=true +LOCAL_IMAGE_ALLOW_REMOTE=false ETSY_API_KEY= ETSY_CLIENT_SECRET= + EBAY_CLIENT_ID= EBAY_CLIENT_SECRET= -EBAY_RUNAME= +EBAY_RU_NAME= EBAY_SANDBOX=true + +SMTP_HOST=smtp.example.de +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=Vendoo + +PORT=8124 + +# Docker veröffentlicht standardmäßig nur an 127.0.0.1. Für NAS-Reverse-Proxy ggf. bewusst anpassen. +VENDOO_PUBLISH_ADDRESS=127.0.0.1 +VENDOO_HOST_PORT=8124 + +# Vom Smartphone erreichbare Basis-URL für QR-Fotouploads. +# Beispiel im LAN: http://192.168.178.50:8124 +# Öffentlich ausschließlich per HTTPS verwenden. +PUBLIC_BASE_URL= +# Nur für den geführten VPS-Modus; setup.sh trägt die Domain automatisch ein. +VENDOO_DOMAIN= + +# Optional: öffentliche JSON-Manifest-URL für die Updateprüfung +VENDOO_UPDATE_MANIFEST_URL= +# Server-, Mehrbenutzer- und Deployment-Sicherheit (1.36.0) +# Standardmäßig nur auf diesem PC erreichbar. Für LAN bewusst auf 0.0.0.0 setzen. +VENDOO_BIND_HOST=127.0.0.1 +# Kommagetrennte zusätzliche Hostnamen/IPs, z. B. vendoo.intern,192.168.178.50 +VENDOO_ALLOWED_HOSTS= +# Kommagetrennte zusätzliche Browser-Ursprünge, jeweils mit Protokoll und Port +VENDOO_ALLOWED_ORIGINS= +# Nur hinter einem korrekt konfigurierten Reverse Proxy aktivieren +VENDOO_TRUST_PROXY=false +VENDOO_SESSION_DAYS=3 +VENDOO_SESSION_IDLE_MINUTES=120 +VENDOO_READ_RATE_LIMIT=360 +VENDOO_MUTATION_RATE_LIMIT=120 + + +# Produktionsbereitstellung 1.36.0 +# Mindestens 32 zufällige Zeichen. Wird beim ersten Admin-Setup abgefragt. +VENDOO_SETUP_TOKEN= +VENDOO_REQUIRE_SETUP_TOKEN=true +VENDOO_COOKIE_SECURE=auto +VENDOO_COOKIE_SAMESITE=lax +# Konkrete Extension-Origins, z. B. chrome-extension://abcdefghijklmnop... +VENDOO_EXTENSION_ORIGINS= +VENDOO_ALLOW_ANY_EXTENSION_ORIGIN=false + +# Optionale getrennte Laufzeitpfade. Windows kann die Standardwerte leer lassen. +VENDOO_DATA_DIR= +VENDOO_DB_PATH= +VENDOO_UPLOADS_DIR= +VENDOO_BACKUP_DIR= +VENDOO_OPERATIONS_DIR= +VENDOO_LOG_DIR= +# Docker setzt automatisch /app/data/config/vendoo.env; lokal bleibt dieser Wert leer. +VENDOO_CONFIG_PATH= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5199ab5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Masterluke77 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..876f32a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '05:00' + timezone: Europe/Berlin + open-pull-requests-limit: 5 + groups: + npm-production: + dependency-type: production + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + time: '05:30' + timezone: Europe/Berlin + open-pull-requests-limit: 5 + - package-ecosystem: docker + directory: / + schedule: + interval: monthly + time: '06:00' + timezone: Europe/Berlin + open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..202b92f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Änderung + + + +## Sicherheit und Daten + +- [ ] Keine `.env`, Tokens, Datenbanken, Uploads, Backups, Logs oder Modelle enthalten +- [ ] Bestehende Benutzer- und Artikeldaten bleiben erhalten +- [ ] Originalbilder werden nicht überschrieben +- [ ] FASHN VTON / Virtual Try-on wurde nicht wieder eingeführt + +## Prüfung + +- [ ] `npm ci` +- [ ] `npm run verify:git` +- [ ] `npm run check` +- [ ] `npm run verify:architecture` +- [ ] `npm run verify:deployment` +- [ ] `npm run verify:db` +- [ ] Windows-/Docker-/Browser-Abnahme dokumentiert oder ausdrücklich als offen markiert diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..748cf56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: Vendoo CI + +on: + push: + branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**'] + pull_request: + branches: [main, develop] + +permissions: + contents: read + +concurrency: + group: vendoo-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Syntax, Sicherheit und Release-Gates + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten reproduzierbar installieren + run: npm ci + + - name: Git-Sicherheitsgate + run: npm run verify:git + + - name: Git-Sicherheitsgate Regressionstest + run: npm run verify:git-regression + + - name: Git-Staging-Filter Regressionstest + run: npm run verify:git-staging + + - name: Syntaxprüfung + run: npm run check + + - name: Plattformarchitektur und Design Tokens prüfen + run: npm run verify:architecture + + - name: Theme Manager, Accessibility und Komponenten-Tokens prüfen + run: npm run verify:themes + + - name: Deployment-Gate + run: npm run verify:deployment + + - name: Persistenten Konfigurationsspeicher prüfen + run: npm run verify:config + + - name: Geführte Shell-Installer simulieren + run: npm run verify:installer-shell + + - name: Datenbankmigrationen prüfen + run: npm run verify:db + + - name: Docker-Compose-Dateien validieren + run: | + cp .env.example .env + python - <<'PY' + from pathlib import Path + p=Path('.env') + s=p.read_text() + s=s.replace('VENDOO_SETUP_TOKEN=', 'VENDOO_SETUP_TOKEN=ci-only-not-a-production-secret-1234567890') + s=s.replace('VENDOO_DOMAIN=', 'VENDOO_DOMAIN=vendoo.example.invalid') + p.write_text(s) + PY + docker compose -f compose.yaml config -q + docker compose -f compose.yaml -f compose.bind-mounts.example.yaml config -q + docker compose -f compose.vps.yaml config -q + rm -f .env + + - name: Docker-Image testweise bauen + run: docker build --pull --tag vendoo:ci . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2562b54 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,143 @@ +name: Vendoo Release + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: vendoo-release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: masterluke77/vendoo + +jobs: + release-assets: + name: Release-Paket und Prüfsummen + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten installieren + run: npm ci + + - name: Release-Tag gegen Paketversion prüfen + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + EXPECTED="v$(node -p "require('./package.json').version")" + test "$RELEASE_TAG" = "$EXPECTED" || { echo "Release-Tag $RELEASE_TAG passt nicht zu $EXPECTED." >&2; exit 1; } + + - name: Vollständige Prüfung + run: | + npm run verify:git + npm run verify:git-regression + npm run verify:git-staging + npm run check + npm run verify:architecture + npm run verify:themes + npm run verify:deployment + npm run verify:config + npm run verify:installer-shell + npm run verify:db + + - name: Release-Staging erzeugen + run: node tools/build-release.mjs + + - name: ZIP und SHA-256 erzeugen + id: package + shell: bash + run: | + PACKAGE_NAME="$(cat release-output/RELEASE_NAME.txt)" + cd release-output + zip -r -9 "${PACKAGE_NAME}.zip" "${PACKAGE_NAME}" + sha256sum "${PACKAGE_NAME}.zip" > "${PACKAGE_NAME}.zip.sha256.txt" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Assets an GitHub Release anhängen + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + PACKAGE_NAME: ${{ steps.package.outputs.package_name }} + run: | + gh release upload "$TAG" \ + "release-output/${PACKAGE_NAME}.zip" \ + "release-output/${PACKAGE_NAME}.zip.sha256.txt" \ + --clobber + + - name: Build-Artefakte sichern + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.package_name }} + path: | + release-output/${{ steps.package.outputs.package_name }}.zip + release-output/${{ steps.package.outputs.package_name }}.zip.sha256.txt + if-no-files-found: error + retention-days: 30 + + container: + name: GHCR-Container veröffentlichen + permissions: + contents: read + packages: write + attestations: write + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + + - name: Docker Buildx aktivieren + uses: docker/setup-buildx-action@v3 + + - name: Bei GHCR anmelden + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Container-Metadaten erzeugen + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ github.event.release.tag_name }} + type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }} + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} + + - name: Container bauen und veröffentlichen + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + provenance: true + sbom: true + + - name: Container-Provenienz attestieren + uses: actions/attest@v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + diff --git a/.gitignore b/.gitignore index 2207124..df9e144 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Environment and secrets +# Secrets and local configuration .env .env.* !.env.example @@ -8,51 +8,77 @@ *.p12 *.pfx *.crt -*.cer -# Node.js +# Dependencies node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -# Databases and SQLite runtime files +# SQLite runtime data db/*.db db/*.db-shm db/*.db-wal db/*.sqlite db/*.sqlite3 -*.db-shm -*.db-wal -# Runtime data +# User and runtime data uploads/* !uploads/.gitkeep -backups/* -!backups/.gitkeep +backups/ logs/ -*.log tmp/ temp/ sessions/ data/ docker-data/ +*.log -# Generated files and local state -coverage/ -dist/ -.cache/ -*.pid -*.seed - -# Editors and operating systems -.vscode/ -.idea/ +# OS and editor files .DS_Store Thumbs.db desktop.ini +.vscode/ +.idea/ -# Local Docker overrides +# Local overrides compose.override.yaml docker-compose.override.yml + +# Local AI runtimes and downloaded model data +local-ai/comfyui/ +local-ai/downloads/ +local-ai/extract-temp/ +local-ai/fashn-vton/.venv/ +local-ai/fashn-vton/engine/ +local-ai/fashn-vton/weights/ +local-ai/fashn-vton/outputs/ +local-ai/fashn-vton/runtime/ +local-ai/fashn-vton/hf-cache/ +local-ai/fashn-vton/fashn-vton-*.zip +local-ai/fashn-vton/python311/ +local-ai/fashn-vton/uv/ +local-ai/fashn-vton/uv-cache/ +local-ai/fashn-vton/downloads/ + +# Local update, rollback and slice archives +updates/ + +# Operations Center runtime +operations/staging/ +operations/updates/ +operations/rollback/ +operations/apply-*/ +operations/pending-operation.json +operations/last-operation.json + +# Deployment assistant state +.vendoo-install-mode +runtime/ +# Generated deployment and release output +release-output/ +*.release.zip +*.release.sha256.txt +.vendoo-github-state.json + +# First-run and local installer state +operations/first-admin-code.txt +config/local/ + diff --git a/.migration-image-stack-cleanup-1.20.0 b/.migration-image-stack-cleanup-1.20.0 new file mode 100644 index 0000000..174ad29 --- /dev/null +++ b/.migration-image-stack-cleanup-1.20.0 @@ -0,0 +1 @@ +Vendoo 1.20.0 image-stack cleanup completed. \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a9758f6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,9 @@ +registry=https://registry.npmjs.org/ +audit=false +fund=false +strict-ssl=true +prefer-offline=true +fetch-retries=5 +fetch-retry-factor=2 +fetch-retry-mintimeout=10000 +fetch-retry-maxtimeout=120000 diff --git a/Archiv/03_IST_UND_LUECKENANALYSE.md b/Archiv/03_IST_UND_LUECKENANALYSE.md new file mode 100644 index 0000000..4c5e376 --- /dev/null +++ b/Archiv/03_IST_UND_LUECKENANALYSE.md @@ -0,0 +1,33 @@ +# Vendoo 1.31.0 – Ist- und Lückenanalyse + +Stand der Prüfung: 09.07.2026 +Geprüfte Basis: `Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip` +Verifizierte SHA-256 der Basis: `cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b` + +Die Bewertung basiert auf dem real enthaltenen Code. Paketnamen und Dokumentation wurden nicht als alleiniger Funktionsnachweis verwendet. + +| Bereich | Status in 1.31.0 | Reale Fundstelle | Risiko/Lücke | Änderung in 1.32.0 | +|---|---|---|---|---| +| Versionen | bestätigt | `package.json`, Lockfile, UI-Build und Manifest = 1.31.0 | keine | auf 1.32.0 synchronisiert | +| Rollen/RBAC | bestätigt | `lib/security.mjs`, globaler `apiPermissionGuard` | einzelne Sonderrouten bleiben zusätzlich manuell zu beobachten | unverändert erhalten | +| Sessions/Login-Schutz | bestätigt, Setup-Ausnahme riskant | `lib/auth.mjs`, gehashte Tokens, Sperrlogik, Widerruf; `requireAuth` übersprang im Setup-Modus alle APIs | vor erstem Admin waren geschützte API-Routen unnötig offen; kein Offline-Notfallreset für verlorenen letzten Admin | API im Setup-Modus gesperrt; Notfallreset als Folgepunkt dokumentiert | +| CSRF/Cookies | vorhanden | Double-Submit-Cookie und Header, `HttpOnly`, dynamisches `Secure`, `SameSite=lax` | Cookie-Modus nicht konfigurierbar; Proxyannahme nur boolesch/ein Hop | Cookie-Modus konfigurierbar, Proxy-Hops/Scopes unterstützt | +| CORS/Origin | vorhanden, unvollständig | Host-/Origin-Middleware in `server.mjs` | jede Browser-Extension-Origin wurde pauschal akzeptiert | in Produktion nur konkrete Extension-Origins, unsicherer Opt-in separat | +| Audit/Locks | bestätigt | Audit-Log, `resource_locks`, Adminansicht | keine kritische Deployment-Lücke | erhalten | +| Health | teilweise | `/healthz` liefert Prozessstatus | keine Readiness-, DB- oder Schreibpfadprüfung | `/readyz` mit DB- und Volume-Schreibtest | +| Shutdown | fehlt | direkter `app.listen()` ohne Signalbehandlung | WAL/Jobs können beim Containerstopp hart beendet werden | SIGTERM/SIGINT, Server-Close, WAL-Checkpoint, DB-Close | +| Datenbankpfad | unvollständig | fest `db/vendoo.db` im Codeordner | Container-Image und Nutzerdaten nicht sauber trennbar | `VENDOO_DB_PATH` und zentrale Laufzeitpfade | +| Uploadpfad | unvollständig | fest `uploads/` im Codeordner | keine getrennte Persistenz; öffentlich statisch erreichbar | konfigurierbarer Pfad, Zugriff hinter Anmeldung | +| Uploadprüfung | Sicherheitsrisiko | Dateiendung **oder** `image/*` genügte | manipulierbarer MIME-Typ konnte beliebige Endungen umgehen | Endung **und** Bild-MIME erforderlich; Größenlimit bleibt aktiv | +| Backups | bestätigt, aber codegebunden | verifizierte ZIPs mit DB/Uploads in `operations-center.mjs` | Pfade fest an Projektroot; Live-Volume-Kopie nicht dokumentiert | separate Backup-/Operations-Volumes und sichere Stop/Backup/Start-Anleitung | +| Restore/Update | vorhanden, gestuft | Operations Center und `setup.ps1` | Container darf Anwendungscode nicht in-place überschreiben | Docker-Update nur über neues Image; Windows-Update bleibt erhalten | +| Docker/Compose | fehlt | keine entsprechenden Dateien | kein reproduzierbarer NAS-/Produktionsbetrieb | Dockerfile, Compose, Healthcheck, non-root, read-only, Volumes | +| Reverse Proxy | teilweise | Host/Origin, `PUBLIC_BASE_URL`, `trust proxy` | kein fertiger Produktionsleitfaden | HTTPS-/Proxy-Dokumentation und sichere Standardbindung | +| FLUX/ComfyUI | getrennt im Konzept | externer URL-Endpunkt; lokaler Windows-Installer | Bootstrap schreibt in Projektordner und passt nicht zu read-only Container | Container startet standardmäßig ohne FLUX; externer Dienst bleibt optional | +| Browser-Erweiterungen | real vorhanden | Chrome/Edge/Firefox/Safari-Strukturen und Pakete | Remote-Kopplung nutzt weiterhin Login-Cookie statt widerrufbarem Extension-Token | CORS gehärtet; Tokenkopplung bleibt geplanter 1.34.0-Slice | +| UI-Begriffe/Kernbereiche | weitgehend bestätigt | Navigation, Artikel, Galerie, SmartCopy, Publish und Erweiterungen im realen HTML/JS | vollständige Browser-End-to-End-Abnahme nicht statisch beweisbar | keine unnötige UI-Neuentwicklung in diesem Deployment-Slice | +| VTO | entfernt | kein aktiver FASHN-/VTO-Dienst; freier FLUX-Workflow ohne `LoadImage` | historische Dokumente enthalten weiterhin Kontext | keine VTO-Rückkehr | + +## Ergebnis + +1.31.0 besitzt eine echte Mehrbenutzer- und Sicherheitsgrundlage, war aber noch kein sauber containerisierbarer Produktionsstand. Die Hauptlücken lagen in der festen Kopplung von Code und Nutzerdaten, fehlender Readiness, fehlendem geordneten Shutdown, fehlenden Containerdateien sowie zu breiten Extension-Origin- und Uploadregeln. diff --git a/Archiv/Vendoo_Feature_1_34_2_Git_Safety_Diagnostics_Staging_Filter_Hotfix.zip b/Archiv/Vendoo_Feature_1_34_2_Git_Safety_Diagnostics_Staging_Filter_Hotfix.zip new file mode 100644 index 0000000..2f4b12c Binary files /dev/null and b/Archiv/Vendoo_Feature_1_34_2_Git_Safety_Diagnostics_Staging_Filter_Hotfix.zip differ diff --git a/Archiv/Vendoo_Feature_1_34_3_Git_Staging_Update_Archive_Exclusion_Hotfix.zip b/Archiv/Vendoo_Feature_1_34_3_Git_Staging_Update_Archive_Exclusion_Hotfix.zip new file mode 100644 index 0000000..8fff505 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_34_3_Git_Staging_Update_Archive_Exclusion_Hotfix.zip differ diff --git a/Archiv/Vendoo_Feature_1_35_0_Modular_Platform_Kernel_Design_System_Security_Foundation.zip b/Archiv/Vendoo_Feature_1_35_0_Modular_Platform_Kernel_Design_System_Security_Foundation.zip new file mode 100644 index 0000000..cefcfe0 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_35_0_Modular_Platform_Kernel_Design_System_Security_Foundation.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens.zip b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens.zip new file mode 100644 index 0000000..f206f06 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/00_START_HIER.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/00_START_HIER.md new file mode 100644 index 0000000..fe7d230 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/00_START_HIER.md @@ -0,0 +1,28 @@ +# Vendoo 1.36.0 – START HIER + +Dieses Paket ist ein vollständiges Drüberkopier-Release für **Vendoo 1.36.0 – Secure Theme Manager, Accessibility & Component Token Foundation**. + +## Wichtig + +- Bestehenden Vendoo-Ordner **nicht löschen oder leeren**. +- Keine `.env`, Datenbank, Uploads, Backups, Operations-Daten, Updates, FLUX-Modelle oder ComfyUI-Daten löschen. +- Der Windows-Lokalbetrieb bleibt erhalten. +- Docker, NAS/Portainer und VPS bleiben unterstützt. +- FASHN VTON und Virtual-Try-on bleiben entfernt. +- FLUX bleibt ein separater Text-zu-Bild-Dienst. +- Originalbilder werden nicht überschrieben. + +## Update unter Windows + +1. Vendoo vollständig schließen. +2. Dieses ZIP entpacken. +3. Den **Inhalt** des Ordners `vendoo` über die bestehende Vendoo-Installation kopieren. +4. Dateien ersetzen bestätigen. +5. `setup.bat` starten. +6. Im bestehenden System **Update** wählen. +7. Vendoo starten. +8. Unter **Einstellungen → Darstellung** den Theme Manager öffnen. +9. Light, Dark, System und High Contrast prüfen. +10. Als Administrator ein Test-Theme erstellen, in der Vorschau prüfen und wieder löschen. + +Die Installation und Konfiguration erfolgt über Eingabemasken beziehungsweise geführte Assistenten. Konfigurationsdateien müssen nicht manuell bearbeitet werden. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/01_NEUE_FUNKTIONEN.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/01_NEUE_FUNKTIONEN.md new file mode 100644 index 0000000..e2086ea --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/01_NEUE_FUNKTIONEN.md @@ -0,0 +1,46 @@ +# Neue Funktionen in Vendoo 1.36.0 + +## Nativer Theme Manager + +`vendoo.themes` ist der erste Fachbereich, der vollständig auf den modularen Plattformkernel migriert wurde. Das Modul besitzt eigene Tabellen, Services, Policies, API-Verträge und Frontend-Komponenten. + +## Persönliche Darstellung + +Jeder Benutzer kann über eine Eingabemaske auswählen: + +- System, Light, Dark oder High Contrast +- normale, kompakte oder komfortable UI-Dichte +- reduzierte Bewegung +- ein freigegebenes benutzerdefiniertes Theme + +## Administrative Theme-Verwaltung + +Administratoren können: + +- Theme-Profile erstellen und duplizieren +- ein Basistheme wählen +- ausschließlich freigegebene Design Tokens ändern +- Änderungen live vorab ansehen +- Kontrastwerte prüfen +- Profile importieren und exportieren +- den systemweiten Standard festlegen +- Profile sicher löschen + +## Sicherheit + +- kein freies CSS +- kein JavaScript oder HTML +- keine URLs oder Dateipfade als Theme-Werte +- serverseitige Wiederholungsvalidierung +- getrennte Benutzer- und Administrator-Policies +- Audit-Protokoll für Änderungen +- unbekannte Tokens und Theme-IDs werden abgewiesen + +## Design-System + +- 42 typisierte Design Tokens +- Light, Dark und High Contrast +- erste Komponenten-Tokens für Buttons, Eingaben, Karten und Dialoge +- Fokusbreite und Mindestzielgröße +- reduzierte Bewegung +- bestehendes Design bleibt über eine Kompatibilitätsbrücke erhalten diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/02_INSTALLATION_UND_ABNAHME.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/02_INSTALLATION_UND_ABNAHME.md new file mode 100644 index 0000000..711daa4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/02_INSTALLATION_UND_ABNAHME.md @@ -0,0 +1,38 @@ +# Installation und Abnahme – Vendoo 1.36.0 + +## Bestehende Windows-Installation aktualisieren + +1. Vendoo und offene Node-/FLUX-Fenster schließen. +2. Das ZIP in einen temporären Ordner entpacken. +3. Den Inhalt des enthaltenen Ordners `vendoo` in den bestehenden Vendoo-Ordner kopieren. +4. Windows bei Rückfrage **Dateien ersetzen** lassen. +5. Bestehende Laufzeitdaten nicht löschen. +6. `setup.bat` doppelklicken. +7. **Update** wählen. +8. Danach Vendoo starten. + +## Neuinstallation + +- Windows lokal oder Docker Desktop: `setup.bat` starten und die grafische Maske verwenden. +- Linux, NAS oder VPS: `chmod +x setup.sh && ./setup.sh` ausführen und dem Assistenten folgen. +- Beim VPS werden Domain und Betriebsparameter abgefragt; Caddy übernimmt HTTPS, sobald DNS und Ports korrekt eingerichtet sind. + +Es ist keine manuelle Bearbeitung von `.env`, Compose- oder Theme-Dateien erforderlich. + +## Funktionsabnahme + +1. `http://127.0.0.1:8124/readyz` aufrufen. +2. Anmelden und bestehende Benutzer, Artikel und Bilder prüfen. +3. **Einstellungen → Darstellung** öffnen. +4. Persönliche Auswahl speichern und Seite neu laden. +5. Dark, Light und High Contrast prüfen. +6. UI-Dichte und reduzierte Bewegung prüfen. +7. Als Administrator ein eigenes Theme anlegen. +8. Live-Vorschau und Kontrastliste prüfen. +9. Theme exportieren und wieder importieren. +10. Systemstandard setzen und mit einem zweiten Benutzer prüfen. +11. Sicherstellen, dass normale Benutzer keine administrativen Profile verändern können. + +## GitHub + +Nach erfolgreicher lokaler Prüfung `GitHub-Deploy-Assistent.bat` starten. Der Zielbranch ist `release/1.36.0-theme-manager`, der vorbereitete Draft Pull Request ist #9. Erst nach grünem Sicherheitsgate und geprüftem Git-Status `PUSH` bestätigen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/03_IST_UND_LUECKENANALYSE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/03_IST_UND_LUECKENANALYSE.md new file mode 100644 index 0000000..779463b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/03_IST_UND_LUECKENANALYSE.md @@ -0,0 +1,26 @@ +# Ist- und Lückenanalyse – Vendoo 1.36.0 + +## Erreicht + +- modularer Plattformkernel aus 1.35.0 bleibt aktiv +- Theme-Bereich ist erstes natives Fachmodul +- persönliche und systemweite Darstellung sind getrennt +- sichere Theme-Profile ohne freien Code +- additive Datenbanktabellen für Profile und Benutzerpräferenzen +- 42 versionierte Design Tokens +- Live-Vorschau und Kontrastprüfung +- Theme-Gate in lokaler Prüfung, CI und Release +- erste allgemeine UI-Komponenten nutzen Komponenten-Tokens + +## Bewusst noch offen + +- die übrigen elf Fachmodule sind weiterhin Legacy Bridges +- `public/app.js`, `public/style.css`, `public/index.html` und `server.mjs` sind noch groß +- noch nicht alle Alt-Komponenten verwenden ausschließlich Komponenten-Tokens +- vollständige Tastatur-/Screenreader-E2E-Abnahme auf realen Browsern steht aus +- ein isolierter Drittanbieter-Extension-Host ist noch nicht umgesetzt +- Secret Provider und umfassendes ASVS-Sicherheitsdashboard folgen in späteren Slices + +## Bewertung + +1.36.0 schafft einen produktiven, sichtbaren Beweis für die neue Architektur. Weitere Fachmodule sollten jetzt schrittweise nach demselben Muster migriert werden; ein Big-Bang-Umbau bleibt ausdrücklich ausgeschlossen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/04_ABNAHMEBERICHT.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/04_ABNAHMEBERICHT.md new file mode 100644 index 0000000..b72405f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/04_ABNAHMEBERICHT.md @@ -0,0 +1,31 @@ +# Abnahmebericht – Vendoo 1.36.0 + +## Bestanden + +- JavaScript-/MJS-Syntax +- Plattform-Architecture-Gate +- Theme-Manager-Gate +- Deployment-Gate +- Git-Sicherheitsgate mit 385 geprüften Dateien +- Secret- und Staging-Regressionstests +- persistenter Konfigurationsspeicher +- simulierte Docker-, NAS- und VPS-Installationsdialoge +- 12 Modulmanifeste: 1 natives Theme-Modul und 11 Legacy Bridges +- 42 Design Tokens und 3 integrierte Themes +- additive Theme-Tabellen auf frischer und bestehender Datenbank +- 68 Migrationsblöcke in beiden Datenbanktests +- bestehender Testdatensatz blieb erhalten +- reproduzierbare Paketauflösung mit 151 Paketen über `npm ci --ignore-scripts` +- 384 Quelldateien im Release-Staging einzeln per SHA-256 verifiziert + +## Nicht als real getestet ausgegeben + +- Browser-End-to-End-Test unter echtem Windows +- Docker-/NAS-/VPS-Livestart +- Caddy-/TLS-Livetest +- GitHub-Actions-Livelauf +- native Kompilierung/Ausführung von `better-sqlite3` in dieser Sandbox + +## Ergebnis + +Die statische, architektonische, sicherheitsbezogene und migrationsbezogene Abnahme ist bestanden. Die plattformspezifische Funktionsabnahme ist nach dem Drüberkopieren in der echten Zielumgebung auszuführen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/05_ROADMAP.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/05_ROADMAP.md new file mode 100644 index 0000000..e551f2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/05_ROADMAP.md @@ -0,0 +1,22 @@ +# Vendoo Roadmap nach 1.36.0 + +## Nächster empfohlener Slice: 1.37.0 + +**Security Core, Secret Provider & Observability Foundation** + +- Secret-Provider-Abstraktion für Windows, Docker und Tests +- API-Schlüssel niemals an den Browser zurückgeben +- strukturierte JSON-Logs und Correlation IDs +- zentrale Request-Schemata und Fehlerverträge +- CSP-Nonce-Vorbereitung +- Security Dashboard und ASVS-Matrix +- Backup-Verschlüsselungs- und Rotation-Vertrag + +## Danach + +- 1.38.0: Auth, Users & Settings als native Module +- 1.39.0: Listings, Inventory & Media als native Module +- 1.40.0: Publishing, AI & FLUX als native Module +- später: isolierter, signierter Extension Host + +Jeder Slice muss Windows lokal, Docker, NAS und VPS erhalten, bestehende Daten migrieren statt zurücksetzen und vor Änderungen ein überprüfbares Backup ermöglichen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/FILE_CHECKSUMS_SHA256.txt b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/FILE_CHECKSUMS_SHA256.txt new file mode 100644 index 0000000..fb0eacc --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/FILE_CHECKSUMS_SHA256.txt @@ -0,0 +1,390 @@ +24c6f27679a6107cd9de55495df89bfef6948032f8f65920a1bb7ed08a6ab4a6 00_START_HIER.md +ccc0254f3e2f399f922db8c4f9dbc08b9fa12be334b5850ad5befda62db830cc 01_NEUE_FUNKTIONEN.md +7e879172a9a6207edc9707f5d742dfc39ccef9913e507cc7cc7c8caa5e22c026 02_INSTALLATION_UND_ABNAHME.md +43a560944cb3224152578930e2058f3e6b84fc9a093fc06d0bffc34c7aaadbc9 03_IST_UND_LUECKENANALYSE.md +1d8a9e0f3b7d9190f3a9c3a4fe9caa4b42d55dd3a3e342c0a269bcbec0284935 04_ABNAHMEBERICHT.md +36a4176af45431c0b5be5046d3023e0704b52bd20d63bfcf45fe7f3e8d3b8099 05_ROADMAP.md +f344f75df655bc133e6c4abb2d215b2234fb22a4bb155c217317c36ee4f0f8fb vendoo/.dockerignore +196b4f6848e886de83ce63810e60a2b129ff3e4e71f080efb9611432c4b845be vendoo/.env.example +58e3f4b76cc9398f32dcd5481b2080e7e5ba322aca04b8b8374446299c5d7dd6 vendoo/.github/CODEOWNERS +5bd335eb750d084ca12b6a3b3f35d0cd7bc454ba24ed280187c66acf75eaf396 vendoo/.github/dependabot.yml +f65684e98b01d0e0b6bfe1284b5de3354559e4cd51e634fe9e9e2e5473b890e5 vendoo/.github/pull_request_template.md +f037cc2a7fc97f78646cc834f35ecd28811ac46c960373f24ee2aff8a48558e8 vendoo/.github/workflows/ci.yml +dea64cf55cb8eb8922b0f1156cbce5f4e853dfbc679d9f7b4058a7fd4bdd34d2 vendoo/.github/workflows/release.yml +9e7decb6c419fc032644ad361043e69770104ea5904bfcce4942f9416787d975 vendoo/.gitignore +9bfa732952ad571d06527b32d96d79133fc23fa607df00de440b2802f543608c vendoo/.npmrc +a29b1b7f6fa480d9d7a801e41df54aab5e8e1e5bde52c69794ef22b59bcfc76d vendoo/CHANGELOG.md +61823fc9fdc036170cce6edeecbb612b442d1c65fc9f7a7eaba47e5016316ee5 vendoo/CLAUDE.md +2e0077cd6dd83d3a07fcef25b7e37ab5fc056dbe1ca034b33d55135234045882 vendoo/Dockerfile +3a72c2ba86fca6655af49a47e853a335532586dbf5ea620d909570ec7d76cb02 vendoo/FEATURES.md +7f78a0d512de717ba76c9a5e3e1bd2e52db9edf146e8eeb7297458fde5d92afa vendoo/GitHub-Deploy-Assistent.bat +302ab1609d08b3059ba6563b676c64f0709651f2e3c531857bfca3840c535a79 vendoo/MockupDesign/AdminDashboard.png +49f7625a1299a16576feabfd7824ec3511391bd9b7c53a1fba9232da7e8bd6c6 vendoo/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png +7b8cc9c5db8718b0b653ec6aa9d2a8bfa1bba03810248c3ba17745b6f15cc412 vendoo/MockupDesign/Dashboard.png +4e3e797ed5d3ed0b60d00d8e567fbe51543c08578b858ef71e77b274ae449fd0 vendoo/MockupDesign/Einstellungen.png +c78fd6f673c0e59789ea313396b12c2f21f75a2ee6c7ad2de81a6b89f6d90588 vendoo/MockupDesign/ExtensionDesignMockup.png +ed20bc04cab8e85245a855810a61b736a682f972eb85e23340edc9b558851a93 vendoo/MockupDesign/Heute StudioFlow.png +1ad88f45f377ab9b3f35f96a0ceaaa7f75cb0a996e83370d76c54f527392a08d vendoo/MockupDesign/IconLogoMockup.png +0e8b4a31d075f1f83ea62b5eeeb53e89859e395a696deb43155932a23e7cef86 vendoo/MockupDesign/Lager.png +b1c62e118d9be316274e966c558cdcccdd41afb3a9f05a60ec1e4a3433d2ca0c vendoo/MockupDesign/Listings.png +a52983b9ea2bf920d115485d10bc30226da88bc5cba8b00bebf801b21c096346 vendoo/MockupDesign/Publish.png +8c8bf278d0c6725e1dbb97b3c3d0c36a9bfc0753c3c86433fdc294bec70dbc0e vendoo/MockupDesign/Startseite.png +62f14221b1d71c9e631efdadd221397ff657adae4b737df52c8bff1610b90999 vendoo/MockupDesign/Vorlagen.png +da56aefa73aaa46fa2c1931ddc8075028b75fae68def3a03d2af98c4b1d8db65 vendoo/README.md +332011f0281e4fb21397ccadf6e7e37ceef13242ad43e5a87ad55fa625b3009c vendoo/SECURITY.md +2851c914a1b50a036cb265ddd32e5545cc7e8e34ba9d682445926acc23eb8f28 vendoo/THIRD_PARTY_NOTICES.md +4c424ef64a09639953388bd2e749d45e9a0d3d533c8b6fce547292237a0a48f9 vendoo/app/architecture-boundaries.json +a6bdb9b6fdc97a0b2bbc052bd242a9885a75bcc46ee4f0414d8b7e215b6940de vendoo/app/contracts/module.schema.json +7f38b3ac792e61e030c9f69355e2378f8f61e8e61c7b29c82cd0fa7ec930cfec vendoo/app/core/themes/theme-contract.mjs +d84a573699e413e0a0803459e0d05c5c4e6e35d3499eb634f95302d151e75c4b vendoo/app/kernel/errors.mjs +a34333cbc784d39b39a8071a437972c6175c551104dc2d82fc4c0e0506b18259 vendoo/app/kernel/event-bus.mjs +0e8f691f96e1ef0a7a86d32cae7b7e1db04325fe94a32b8c1f484a5b6950f3b4 vendoo/app/kernel/feature-flags.mjs +a0223a00691908862aaf1fd9383b6d86d67f26325c509280b8c2daa5cfcee28e vendoo/app/kernel/lifecycle.mjs +ba1f4f799a508ff113738cce7ac9f5af27739cb60d7f04d7a6c5878de3c5b7f4 vendoo/app/kernel/module-contract.mjs +1baa99ffd80fffcfed69f21a5db8911500f35c0bd9df23cd02a7fb3b563266df vendoo/app/kernel/module-registry.mjs +3020a4f447f5af3d77efb918e5d074c79e3badea073e3fe0997bc6cf03bde312 vendoo/app/kernel/platform-kernel.mjs +7944f68d995a5d70273f8a985973f668fc62a414d85636d92f3774e34d39a37f vendoo/app/kernel/policy-engine.mjs +8fd120558114a0357d55b070c3a150464f57d6d87a84594c10562774ed994213 vendoo/app/kernel/route-registry.mjs +0bb099111f7cf49780389d0b078083756a448b663661ee22475d84d38a5f9719 vendoo/app/modules/ai/module.json +83ad66e0eba88246d292c96fb1ec02b9c59ed15614bbbf4a4a989787fe51e2d3 vendoo/app/modules/auth/module.json +1e63f30aa7616f5582c2b1d151f5da29ae92dbfdcc5e69d516d8780950debf24 vendoo/app/modules/catalog.mjs +083db2c78089db389e51e743078c847f1067f3d1a39081080c601d31556f15fd vendoo/app/modules/flux-studio/module.json +82b31b548c603cdc4e21b50707fd3d21114b85ef1f61db607c881f9ac1809132 vendoo/app/modules/listings/module.json +6453b41f4074b37c7f7a2c30aa8a0eba8c4233f8d474251919094ed0c15fe38d vendoo/app/modules/media/module.json +9723a9b12a9b472b2ed8c4635cdce663b2f0f65643d8ae0b6219715d853f740d vendoo/app/modules/operations/module.json +b71c0f607bbba70b4485fcce883a7d2ab4a533a6dc203eb7de616aa4d8bae435 vendoo/app/modules/publishing/module.json +61aa66129d18e2c5e6c2e7319de836a4713fde1ddcc31c6a5ca56c22d22d9b48 vendoo/app/modules/quality/module.json +1f02d199bac34cdfe2e41c32dfd450f2160493d9589b11eb1ca595fba74d5daf vendoo/app/modules/security/module.json +452a5262c418b393bdd8754fe90f9ea14edf61338f8dcfd6438f0852920f608c vendoo/app/modules/system/module.json +92d968c993ee5a2afc14df557b7cba552cae9a41722989007eb0b784514333ff vendoo/app/modules/themes/index.mjs +1e062db871f9bf13b487c972f6cd4dd9cfe1b3046cd9b53ed43e0a0babc9d285 vendoo/app/modules/themes/migrations/001_theme_profiles.sql +2af572390890e4d793aea9c1356c835917655c225a7848325d5a7c5ae3c08a09 vendoo/app/modules/themes/module.json +3c3dc5974976672cd0e9141867a8a1d78a0dfaa54582bafb8f360bea571bf11a vendoo/app/modules/themes/repository.mjs +4e2c348a184384739408894f26b02c56c6bfd4dd531eebb9c67d2fb7fbad3009 vendoo/app/modules/themes/service.mjs +11b4a1bc94b68998a3a2a8bec766bc56c7ff829939d914b0ce896f963feb10ed vendoo/app/modules/users/module.json +69ce3be6a8a4a28725c5c7f9dc8803415ab0d1e503047b66dfcc9a44c9e90109 vendoo/bootstrap.mjs +4a3273ec603224a70ceb81c0d5ed6617c3ba2d3111342b4e646a79752f4b3f87 vendoo/compose.bind-mounts.example.yaml +9f865115da62b2689e06156da34c97e10a0b9831a77f967f7f418e100543d789 vendoo/compose.vps.yaml +0226a6661724b6a5a88a615dc7955f7c601930b7a3f6f4b328becd0f6866f46b vendoo/compose.yaml +6323f984f1e04ab135356a14ec151941638041f6b305ed68772d5e1812972075 vendoo/db/schema.sql +49690c4e4c8e84de8eb40ffe79823493b7be20fe46fa2ad9e6544f7f0afbfd2c vendoo/deploy/Caddyfile +cc794bca282d79a27d860d0bfef4641769f4e9f31172778a1d9d8205558d948e vendoo/docs/ABNAHMEBERICHT_1_32_0.md +de0d86f806a70195050613f5b56f6c4da1ee2eff02a9a991b6aacee229c46ca5 vendoo/docs/ABNAHMEBERICHT_1_33_0.md +947745174897470de0bebf4f7c2b29a0c0ef3f1a762f277374c2614d9e3dec4f vendoo/docs/ABNAHMEBERICHT_1_34_0.md +26c87fa38f804749a9363812127dc33ef69e201e4ef9b59206f6fe6e15c845b0 vendoo/docs/ABNAHMEBERICHT_1_34_1.md +f792d366c13c5a4a9b4e8d4054e26190d9c327a7a770005d74bbe2b31bb84ab3 vendoo/docs/ABNAHMEBERICHT_1_34_2.md +bc638fd21222ada7b15e40adff80a318aebec58139978109cbfd30040815a8d4 vendoo/docs/ABNAHMEBERICHT_1_34_3.md +c0ae5c4176ba6f7510be047d4feaf383cd646152131003ebccc6bfbf8c8056f5 vendoo/docs/ABNAHMEBERICHT_1_35_0.md +c050c8e11f2bdb0a742851423dcff864bafba9cc839c52b2f8d48b75f7b18633 vendoo/docs/ABNAHMEBERICHT_1_36_0.md +67a1be91b0c2d2797716f785f2f770c881cf88d7fd617722468df8d949d0638a vendoo/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md +2acba8fcc5da42e5a808925a86628bc0c6fc817ddb082b0ead3857c6a43460f1 vendoo/docs/BRANDING.md +f23c643236c84b0e3715620598f358065748b6d090229003e23df8ad9320b54e vendoo/docs/FULL_LISTING_EDITOR_2026.md +ba9a9b93622fadc399cd9067d80b4ea339193eb19825106264a3b86b7128d107 vendoo/docs/GENERATOR_STUDIO_2026.md +54f45ae5c2f12b6314b3f1bc3e59e753c64ca7cfe39644725d765e9f02f637ef vendoo/docs/GITHUB_DEPLOYMENT_1_34_0.md +5a25e952d7d79b562bc19c990451830ea2ac3e730c9af399425ea18ffb8fd61d vendoo/docs/GITHUB_IMPORT_STATUS.md +65d19c1576f4a6011008a7e447a3530222517752fb59ebc5bbb506b791e32605 vendoo/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md +c9c8f934a0c4192dae29df913588188aab1321364d1c46b9c66da5c754e9df41 vendoo/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md +2306ff399000c07a9d7e260a58d92c748fa3219922a4dda25f6b2121a1ad2dbc vendoo/docs/HOTFIX_STUDIO_FLOW_DETAIL.md +0584257245a29de62a6374eb8b114bd19fc6a36306ed990d820ed2062514d6a6 vendoo/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md +7c1afdefd1e3a75a5d6b49aedae80e266bab4e91cbfc657fb862ddfd737dc792 vendoo/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md +1475e484cccb0db67e02abbb05f8b8e676c85536ec4f5bda7dd99d4563fbc049 vendoo/docs/INSTALLATION_1_34_0_ZERO_EDIT.md +1df2aed413865c03e541e66249c1df3eed6d3c3aea9f0ffdeaee40453665df14 vendoo/docs/LISTINGS_UI_2026.md +4f74fef6877126d2579356b9731955e29f05ea8b55b59abb91569e47730a1c3f vendoo/docs/PUBLISH_CENTER_2026.md +bae236366d6d2cb0139e4dbf8baf099599bbff27aacc9eeeba20fdff644f5fb0 vendoo/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md +5223408e51f35ba5f6ce86155544311dd13f60522a6d482f372d64788ab466ac vendoo/docs/RELEASE_1_24_0_VERIFICATION.json +b66524ffd19976f650c6b26b299898b182355950f545c131de16c89fcffe0ebe vendoo/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md +1c5ebf1a496c9571fe9d941793371f9f0be89d85a587c8fa9532f9b25bba92bc vendoo/docs/RELEASE_1_25_0_VERIFICATION.json +1eef86e13d9429701e9d60894f36fe0eba82c974565b1a6687ed157ca6da680e vendoo/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md +14475ae4169f4bde4a5b7ae5512867f972ede69dcb621d7cc6a208d4fdc6a331 vendoo/docs/RELEASE_1_25_1_VERIFICATION.json +f02b80194f434dd3d82b5c4c111e8cae65f1f053a267cec2793a1ff551daff9a vendoo/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md +570522a6513134227122da1b5f2f56a421c62d3b0b5adba703872e9026fa2b58 vendoo/docs/RELEASE_1_26_0_VERIFICATION.json +4cfc47f35765259cbd6a841f51a85fa86600991d1ca545b10756f15b6d332f7f vendoo/docs/RELEASE_1_26_1_STABILITY_GATE.md +9646b913994b1bf8ce03eab57e7a6a87061540e44fdde63ad5b7eee93ca9f475 vendoo/docs/RELEASE_1_26_1_VERIFICATION.json +7c6c3a2e4ce478f47e540a34a3505dbbe11b8f2175d0b274bec7643457997140 vendoo/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md +6f9c206869ba94c461b1d9f8e40847792b8891bb27809a0c6fa3d1d68a4e8598 vendoo/docs/RELEASE_1_30_0_VERIFICATION.json +5da62e06d768ce832d18fb3ecb3ccca2f051a6218d2d3cb492d1e5aebbcccb52 vendoo/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md +6288a31dfa61f71d13f79d40ab64bb7ca475de07ada154c002cd4b0d44a284dc vendoo/docs/RELEASE_1_31_0_VERIFICATION.json +4ee88ff8c3c655cb31bc18192a37ffdd0eedf8e26039b252094d5ce169c1883c vendoo/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md +628d4d1a6c7d837a0c8c22958253193642ddfeaab5f425f418f9f32b46e84340 vendoo/docs/RELEASE_1_32_0_VERIFICATION.json +624919ea60969079f767068771800dbc04ea7a87eb1a53d7f0497447492c25cb vendoo/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md +3f9ae39792bec600ddd534284ae62cb2b4f9db01fb54c09cee8ae161551caa2a vendoo/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md +c724b699894d5ec3c050af0253c6de0c645a789219a344ad7ffe31e330d9921b vendoo/docs/RELEASE_1_34_0_VERIFICATION.json +7caa9e7b1e51af362332e9c50165b60c54c9219c7384c8313d3735c387df1c01 vendoo/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md +ac834ee57ab6bd8fe3dd0238837193e66e01d7e78590a289347b002591da3fb4 vendoo/docs/RELEASE_1_34_1_VERIFICATION.json +51057d17e2636ef1bdfc6751dfad29ebd34fbe7f41b870f8f2f756a5c97d1c4a vendoo/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md +4c2c141ffc85b1d7517672abf2b28bb19fe4769505c0eb4d37e22bae06826cb3 vendoo/docs/RELEASE_1_34_2_VERIFICATION.json +d55eaec79e10800a930fa030367ffa34037755e5757bc532e72d25711c5ca09a vendoo/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md +4bb731af51e786a4827ec48bc7c4d4843c287456aedc09d8b74aa0eed3c0ec16 vendoo/docs/RELEASE_1_34_3_VERIFICATION.json +e7bb3bb950e3c5303746f43139ca8f7c985d2d73896b44f5b707dd46a9152529 vendoo/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md +33eb9f0059a2ddc4b4debe5bf733720f2a5bb22f2710bab0a0cc9ac1b898ff11 vendoo/docs/RELEASE_1_35_0_VERIFICATION.json +5a9fcfa0ea6e9c5c13f6d943e1de4d31c21e21fcff9186e8a714d7a16ed1c18f vendoo/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md +2dfe705857424865f101e861f4e633c8a7fb3c0e8dab874cb6620fd09b1ca902 vendoo/docs/RELEASE_1_36_0_VERIFICATION.json +fe5756f788b2ab131f0832504d90d46d1ed744bfe470fd040803985234afa3cf vendoo/docs/SLICE_02_ACCEPTANCE.md +05614ecf41becc399acfa6a3b2f681f37a8c80f5ce0130b98c578b2c8c60713c vendoo/docs/SLICE_05_ACCEPTANCE.md +0838bcc6464a6beff39f0b4b056e513c1a390193521d9a8fc5d440d38034e9d0 vendoo/docs/SLICE_06_ACCEPTANCE.md +ae0f7c6f6e614ec77e362db1076288596ec48d11b78f33f26908fbc40ae5dd18 vendoo/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md +b561983736639a08146d3642b718651907853d589ee32e5f421fc090f2c75a20 vendoo/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md +c5bf7a0c9f787f100e422d0deb27bffc2c8c86b0e7c1ce1f35d2de8d7d8ea774 vendoo/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md +876675bc3015838f568a6970ef6c01c9fcafe1d8d138c4379bc0f2ee1fd7445b vendoo/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md +223f50d05096b3bfa3ac3a9f0880a2055fc2029b7d70fabd086612c3348cb34a vendoo/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md +95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269 vendoo/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md +88f5a2d021874764b6a7f56c690a1e2112155b83cdf0d4d964b2c6e00ca756de vendoo/docs/SLICE_22_3_SHA256.json +791a5d30f0d6ff9664722a2d00dff0db0bbdf2207e83ab391a40d6c4ab913bba vendoo/docs/SLICE_22_4_SHA256.json +d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807 vendoo/docs/SLICE_22_4_UTF8_BOM_FIX.md +f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe vendoo/docs/SLICE_22_4_VERIFICATION.json +e140f3ee3298b3894126e37900b04ff1073ed400e6414e35897815b3f674d93e vendoo/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md +b4b01b9f3751940d184d6fff269108798c7ec62ca7796b923cf82abbc142ff3b vendoo/docs/SLICE_23_VERIFICATION.json +05e3fcf5aa9a0d8d53175a3d24c5feb36e55f73edfeadf05384e87a5de73353a vendoo/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md +43d867bb8b455f7450e5146648b28c759e50ebab45855d75a36dbc9f91d3f654 vendoo/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md +0bdbeed4b57767427afbebeddc30ea58a211f851663939d6301af38820cf098f vendoo/docs/SLICE_28_VERIFICATION.json +1e20fd1abea3ce61f7f9cc88c6e8e4ddb0fe3513c5600e41b8f7c3f8d38e3ae9 vendoo/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md +1df7305e6a43212a582d5f83630e0ba04fbdd3da0d673d9dcb8f1c5f7ca37731 vendoo/docs/SLICE_29_VERIFICATION.json +07d1ad0c19567cbdb05623567816f1506197a31e86fbcd1742f4ffa300335ae4 vendoo/docs/SLICE_30_FLUX_STUDIO_PRO.md +008237388c4816d3cd67f3f0abfd0512f4ebf2dce75ab6790de41b0481ae1d97 vendoo/docs/SLICE_30_VERIFICATION.json +db1674505e2ca7eb36c8752ea9b2d6fe2f2c12a5e6a1584e2722e20445b05e34 vendoo/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md +3133a06e68cd98e615797665956f1bc401c0427045298b46c48ca590be3ef9da vendoo/docs/SLICE_31_VERIFICATION.json +d425fc1724ac55853bf3433348486de8f8aab111f53fefee3c532beb1deb82af vendoo/docs/SLICE_32_BATCH_IMAGE_FACTORY.md +0a8917412d000168956f7ac55e60ab79b54a8834deef0386a681bb8f920514cd vendoo/docs/SLICE_32_VERIFICATION.json +25ff8492360d473e99c75c308be5f75cf84e0c11574d42aa6553d44f8bb35582 vendoo/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md +1d5411f69c62222d3155f0e89f2b4b5a3effe317ea1e59347efa7730a8e1b0e9 vendoo/docs/SLICE_33_VERIFICATION.json +1161decfabd73c40ff1d2ffbc7d1b9b5168a8fb5fa392187c1b431e9a7f3a78f vendoo/docs/SLICE_34_PUBLISHING_HARDENING.md +aa7ebc90470071566749c61b0dece4169f87666dcca4b88922f7fbafcfdd5a84 vendoo/docs/SLICE_34_VERIFICATION.json +4b66a14b4515e38477f10325ab9d03d9782ad0d426845aefe2d508986ed2cd81 vendoo/docs/UI_FOUNDATION_2026.md +5e655fcf3bb1182f8850b4104b56697ea952b01d2698af6d3d40b05e338b3506 vendoo/docs/UI_UX_FIXES_SLICE_03.md +e5f065ba8d516a5ebd47e7e6859d0aab40ef69797413e358d969ef27c454fcdd vendoo/docs/architecture/DESIGN_SYSTEM_1_35_0.md +cc0edf92756a51414563b4407dedb21d1e3b36682f4bfbbc334aeb33a22ae1b4 vendoo/docs/architecture/MIGRATION_ROADMAP_1_35_0.md +eb47b055dd336e8360ec7c4712dee4ae37f109f3820e3f185969e2327d790cf8 vendoo/docs/architecture/MODULE_SYSTEM_1_35_0.md +d7773faac0e93510a5a51c91de997df03547b51f3124267dcf137d4e99299562 vendoo/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md +771c4771c4d7335750c5a2d976c765b4ed66ed936d8550d7af69de14a16705bb vendoo/docs/architecture/THEME_MANAGER_1_36_0.md +cc77a92ca268d3c444c6113a9cfaa38d3c805a579f6b1b21b7dd43a6ada58551 vendoo/docs/superpowers/specs/2026-07-06-autolister-features-design.md +c7db96df5fed3c8336062204322d62b4870f5c8ab1d30ea48cc6e7e38ed3513b vendoo/extension/README.txt +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extension/background.js +b86f00c7afa1dc921dd66f5cfea8c817eebc8876b9daca6a55dfd03680156c79 vendoo/extension/content.css +8ee8d1a94bbf83e5d09aee3c7d8c4ea5f216b2a51e73b6dbc74f7e6b99017a1c vendoo/extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extension/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b vendoo/extension/manifest.json +e61fd8e1264995fd633bd76e71329813dd02f361286329832a18dfd5a8bdd372 vendoo/extension/popup.css +bdf5b794473cae818789cf95a28a9c9a9d50f81529d72fdf45a536247a783922 vendoo/extension/popup.html +d7893cfdd686d4a6a1bea333468d4ba72cfff0e7166687181a2d37ba1faf3332 vendoo/extension/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extension/vinted-categories.js +48b5f9f4ecc1c1c42f37321e9bb2fc857c646b9c16c34520fc7d4ca59a1036ac vendoo/extensions/README.md +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extensions/background.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extensions/chrome/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e vendoo/extensions/chrome/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 vendoo/extensions/chrome/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extensions/chrome/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extensions/chrome/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extensions/chrome/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extensions/chrome/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extensions/chrome/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b vendoo/extensions/chrome/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 vendoo/extensions/chrome/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a vendoo/extensions/chrome/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 vendoo/extensions/chrome/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extensions/chrome/vinted-categories.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e vendoo/extensions/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 vendoo/extensions/content.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extensions/edge/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e vendoo/extensions/edge/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 vendoo/extensions/edge/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extensions/edge/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extensions/edge/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extensions/edge/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extensions/edge/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extensions/edge/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b vendoo/extensions/edge/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 vendoo/extensions/edge/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a vendoo/extensions/edge/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 vendoo/extensions/edge/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extensions/edge/vinted-categories.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extensions/firefox/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e vendoo/extensions/firefox/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 vendoo/extensions/firefox/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extensions/firefox/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extensions/firefox/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extensions/firefox/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extensions/firefox/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extensions/firefox/icon.svg +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf vendoo/extensions/firefox/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 vendoo/extensions/firefox/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a vendoo/extensions/firefox/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 vendoo/extensions/firefox/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extensions/firefox/vinted-categories.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extensions/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extensions/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extensions/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extensions/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extensions/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b vendoo/extensions/manifest.chromium.json +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf vendoo/extensions/manifest.firefox.json +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 vendoo/extensions/packages/vendoo-link-chrome-1.9.0.zip +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 vendoo/extensions/packages/vendoo-link-edge-1.9.0.zip +74c219587cf0f827a1a1e2b8adacac55477be717fd4ae80e2ae027a9ffb0744d vendoo/extensions/packages/vendoo-link-firefox-1.9.0.zip +9e828844f41154c87ccfc5d8f3a19476396e2782fbd0f956465b42fb508cb098 vendoo/extensions/packages/vendoo-link-safari-1.9.0.zip +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 vendoo/extensions/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a vendoo/extensions/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 vendoo/extensions/popup.js +afd76deccd8bcf26d39f8c86deb1de578873ddd8b09ac18eebacae33169bb420 vendoo/extensions/safari/README_SAFARI.md +0b2a03d06faae7aaebeee8ec9fe1864422c8651f17ae90b185fd31474ac5eeb0 vendoo/extensions/safari/build-safari-extension.command +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e vendoo/extensions/safari/web-extension/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e vendoo/extensions/safari/web-extension/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 vendoo/extensions/safari/web-extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/extensions/safari/web-extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/extensions/safari/web-extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/extensions/safari/web-extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/extensions/safari/web-extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 vendoo/extensions/safari/web-extension/icon.svg +772f1e2e33f4d6d882fa0af663e315fd3b8208141227bb5beb9baaf1f4e08397 vendoo/extensions/safari/web-extension/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 vendoo/extensions/safari/web-extension/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a vendoo/extensions/safari/web-extension/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 vendoo/extensions/safari/web-extension/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extensions/safari/web-extension/vinted-categories.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 vendoo/extensions/vinted-categories.js +9a5bfcafdce53130d7bc36695090a8f87096b4ead9dd5ccc3d46d1d80c07a0fa vendoo/lib/ai-claude.mjs +b53b7a8dea3bd46bee9c32e2ea840d618b5cb31cceba49937c057ec615fb87b5 vendoo/lib/ai-flux-prompt-jobs.mjs +347778ce521a1919ec0679918171dc625d7f04d6dec26c8a4700e702bc78de67 vendoo/lib/ai-local-images.mjs +aeb5103c0179232b9954b7854be8099cf4bb0f3ef1ee908eb5e4faf6a8f1205f vendoo/lib/ai-local.mjs +cad43695655fc1c805815d9d694f23bd41124c1b78bb6493e7fea8893c464320 vendoo/lib/ai-model-jobs.mjs +9f474f665d6d610fe00d9e707e85314a854abb906e31db2bb9a3a7c76785ff4f vendoo/lib/ai-model-photos.mjs +3fb484456b9e6374f14b53a2276eea8533642b7c9073bb73a1bb0dbed7048bbd vendoo/lib/ai-openai.mjs +581c10b8346d0aa10169ed23ed49c038951ecd5cac014722c1080bbd5aec1c5b vendoo/lib/ai-openrouter.mjs +271696d7cbef3581b667a8ce3b07d0b17b1e8b467fbede4d8aa512ecc5225515 vendoo/lib/ai-router.mjs +76a2f1fdf60053739d82962264219060807022dfc9a2b3a644946fddff02a8d9 vendoo/lib/archive.mjs +ee6aa662a1875f9df8da1000804a6061f1a0f52c7a266df3d515cab4d410465b vendoo/lib/auth.mjs +3e2b75c0758703e590233cbcc9b589e3177134accecf5ed921791891cbfc8522 vendoo/lib/categories.mjs +86793fb57a1dad32f4d93e34c27ffe9e77a95785dd30b2b55ffb67d0ad5e14fb vendoo/lib/config-store.mjs +8c203e70e9ab07b996d4a53031892bbf37b10efd024f9e0013266cf2af0244a4 vendoo/lib/db.mjs +75827cb79cebc95cf9ae25469efe28c04cebac2900b62f731552329f0c055e91 vendoo/lib/ebay-api.mjs +b4460619cb14a4e4b810adc78bdd662c9d375c483c979ef619f8b95dfefd3f73 vendoo/lib/etsy-api.mjs +5b2c566ff14fa502ba2cd4d4c918fe238a8e93c3283d521144093a897f9d8018 vendoo/lib/fees.mjs +7dcb2fb5606140afb7a019094d20ef88e9b4e2c70041f77b06d67d8992fd25ce vendoo/lib/html-templates.mjs +844f0f8bc1de849bacc8df5d57006d5fc1100c706acc1140939a981dfdf2df4b vendoo/lib/image-batch-jobs.mjs +5ca5fc65a7de7c27243646d291dc42c0b5267a47705a09b306cd2c2eae2a77c8 vendoo/lib/image-editor.mjs +5e7aa66faddd3d19bc6ebb9ddc13abfc4ab4f17d8c51bb39b2c4e0279385d788 vendoo/lib/images.mjs +0248ef19a04e8f1265cf89c170875c3a8051c91c6151b762a849cb55104e93f3 vendoo/lib/mailer.mjs +ad53b2b39f894a685f79e723b3f2b987fc9d9a3b75d39d0059ad225bc36c08cf vendoo/lib/operations-center.mjs +78d30585aac7dc663e803a19927c89ab02414c0495d81df503e28f7bae58701a vendoo/lib/publishing-jobs.mjs +3a728417dd668d60fd1526ec738cb9b302f86c01d62b2227f12b2e44b7d389c7 vendoo/lib/quality-center.mjs +faa5d84ca6b26d1602e53a402de74a5c9e3f9ef9a72233159101f2b0bb0e499f vendoo/lib/runtime-paths.mjs +cbd60791ee86fe09f07fc9ed85be3654f7aa5a310baafd9053440952619e2bc9 vendoo/lib/security.mjs +ddb9c7677600795a4adf8a19a7525b7551455459d16eed4cdfbe751ef9ddf0eb vendoo/lib/zip.mjs +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 vendoo/local-ai/start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 vendoo/local-ai/stop-local-flux.ps1 +5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265 vendoo/local-ai/workflows/vendoo_flux_schnell_api.json +2571f42e92a40e5483c762b1489b38294627dccfeecf388ac2b1156d82666d8b vendoo/package-lock.json +098e7758e0cf9fa92d2ab1e9c01c62e59196576c79d845292b8a36ca4ed6960a vendoo/package.json +3da71dd28cb25c7c7317b9061855d40c6a3ab8bc39391963b49ee1e707f838e5 vendoo/platforms/ebay-de.mjs +cc1ca2b4384c8c548bf755dc33101d248541bf893c3d5f35369961f904afcc00 vendoo/platforms/ebay-ka.mjs +fa52b247dd758aeee06120499bd1ae6c53e9f460f8bd7762b8f06141b201e32b vendoo/platforms/etsy.mjs +5c925e3934a4c9e2f26dc15b3449a249f5ce20ecebbd459b15365a34804824d6 vendoo/platforms/vinted.mjs +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 vendoo/public/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f vendoo/public/android-chrome-512x512.png +90689f16d004d8ddd3ae4edd5ec0ca1bde89901fb36de163aadd10d710bf347c vendoo/public/app.js +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 vendoo/public/apple-touch-icon.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 vendoo/public/brand/favicons/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f vendoo/public/brand/favicons/android-chrome-512x512.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 vendoo/public/brand/favicons/apple-touch-icon.png +a0f1e01de4e8215e690c1ce869fecaae9d7ba3cc95883340a53ff38f6be52ce0 vendoo/public/brand/favicons/browserconfig.xml +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/public/brand/favicons/favicon-16x16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/public/brand/favicons/favicon-32x32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/public/brand/favicons/favicon-48x48.png +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 vendoo/public/brand/favicons/favicon.ico +2daa93cca0ad2c0cab90a9e8ad4ea0ae4b64feeb68c422954020d17e2376a381 vendoo/public/brand/favicons/html-snippet.txt +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 vendoo/public/brand/favicons/mstile-150x150.png +c372675942caa5010340f21fe521adc72af537d909008731976820fd84604d42 vendoo/public/brand/favicons/site.webmanifest +b2dc767e7a01395d23a7f3f5915324db1a7d1912bac60470d2f7c10815895173 vendoo/public/brand/icons/vendoo-icon-120x120.png +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf vendoo/public/brand/icons/vendoo-icon-128x128.png +d276f50b298e4dfcbba9147550463aee498531280399167d1e9774aa8e5f7e21 vendoo/public/brand/icons/vendoo-icon-144x144.png +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 vendoo/public/brand/icons/vendoo-icon-152x152.png +47122dc91010721e2692d11986f0b91d9b76634130a95723e92b2a070268d16f vendoo/public/brand/icons/vendoo-icon-167x167.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b vendoo/public/brand/icons/vendoo-icon-16x16.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 vendoo/public/brand/icons/vendoo-icon-180x180.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 vendoo/public/brand/icons/vendoo-icon-192x192.png +3f2517cfd215cb624ed639d60b064dfa5e88ce71eb7bbebbbfaa70b3d62556db vendoo/public/brand/icons/vendoo-icon-256x256.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 vendoo/public/brand/icons/vendoo-icon-32x32.png +cae3eb737c8ad3a82a679b09d33d991b501bf482e0132e99aa1b8ad064e472b4 vendoo/public/brand/icons/vendoo-icon-384x384.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 vendoo/public/brand/icons/vendoo-icon-48x48.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f vendoo/public/brand/icons/vendoo-icon-512x512.png +bc85af557be532dcce6beb82cf9bd61af683666b00fb61e4b2069f78e60a9a33 vendoo/public/brand/icons/vendoo-icon-64x64.png +01d59b665bc90dddbd34221e3a2ec3af11bff6fb328aab2c9947c03d74b0465d vendoo/public/brand/icons/vendoo-icon-72x72.png +ed3dfa59e989b154fac88a3c72ac840290ee3e1565d44aa75596bf0055c74020 vendoo/public/brand/icons/vendoo-icon-96x96.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f vendoo/public/brand/icons/vendoo-icon-original-1254.png +8fd9901e07dc9172d162706511ac8dcce220a4c907cb3efa9940c9c13fa32526 vendoo/public/brand/logo/vendoo-logo-cropped.png +3f39371abd1efa6cc8366aac9a6d067f3339b8f2a2da77ab8a26c6bdcf8c4fce vendoo/public/brand/logo/vendoo-logo-original.png +b56d07515f6220de7fd0cb149fed598a7361c985ae6ca7cb14329a99e8947aee vendoo/public/brand/logo/vendoo-logo-transparent.png +e83921d2af9930ab27df1abe64374cb9311189bfaaa951068109177443571a07 vendoo/public/brand/logo/vendoo-wordmark-transparent.png +1711a9387f2d6e75797b77c8daa92b94d7a15e128476e4b8aa9d54ccb0692a0e vendoo/public/core/api-client.js +f30395399a5c6d7e0a884b22f28f7648e6343695cee9960d6ff13b34de7b48f1 vendoo/public/core/frontend-module-registry.js +fb3dbcd03f7ed15b773b582c2a2b785c3040d3319aba5b7c682a7e74e1cf3b4e vendoo/public/core/safe-dom.js +38aa57d13aeb3ab70babcb903bfee71e47d80932ed24a73267c13ff81c24e3ba vendoo/public/design-system/theme-contract.json +308f1c0e2ed1de34aa11545a1a660aab02631e422d6c6945e3203d11666784fc vendoo/public/design-system/theme-runtime.js +4d69e2cb6b7f9f63b54d708a5d8b1ebefcdcb9f88a977053db5031c05b098464 vendoo/public/design-system/tokens/source.json +4a9e65edeaee3421edc3dd4831230062494f1a42f8495f4cb979fde8664d06c4 vendoo/public/design-system/tokens.css +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 vendoo/public/favicon.ico +6e5ba2d8fbe559861428ccf4ebe7e8d1c82357d8b29b7548e40398a605f17c28 vendoo/public/index.html +141c261b2d0ea713af140d2e89f98b9c9046584bef60e97d8bf3d6b6f504efa2 vendoo/public/login.html +59291f75eca405e3856bafea76cc601d681060eb6b36bf82a4be3c3ab7eac290 vendoo/public/mobile-upload-expired.html +f046f166d89fc32774b87deb02e6d144fd2f40c0925aac67487e5b7c489a0ec1 vendoo/public/mobile-upload.css +6152e4fe715efbf8fd6a7c4286f23172bc3cf15dde085e5e4f7389ad5e65ad73 vendoo/public/mobile-upload.html +f13846801a8493aa43df6ec3d78b826b21b59e8f21d47735f68fd0b278e440c7 vendoo/public/mobile-upload.js +01f2bd124257220005032bf234d122b9942fdd8fd57ed8195748b2fb1f5a5cfb vendoo/public/modules/README.md +2d7a004db3e22b30ad3a682ba445bcee3fc1c5f0ed5e8fee45b5892c5b335e51 vendoo/public/modules/theme-manager/theme-manager.css +0edc61f0c2bb604c3adc40813b18fe5ca8d9b846ec2a3d9f406ae26ae313d3ee vendoo/public/modules/theme-manager/theme-manager.js +a26f11bfb9627e878961feacf6d56211a40746731a63a7649bb327a383d60639 vendoo/public/style.css +ff525e0af963e6f54636cfdc1bd2d045aaf82e7b0199755eb5cdf4c52110be2e vendoo/public/vendoo-icons.js +deb1a45f6414a2357046ff1d66fc58114549ba5ad695ed40fcceef2aef9f64a8 vendoo/scripts/docker-backup.sh +20e7590a7633b6c61086a5596d0bec1d343f0ff0eecc444ac932130ff363e258 vendoo/scripts/docker-entrypoint.sh +be72c39f00415cd57b69835346f00043841ce9f853b10436eb1799ca42980cb5 vendoo/scripts/docker-restore.sh +3f1005ade493905e37f90314e8b4667189f28297acf88575fee3d2fa5df00216 vendoo/scripts/github-deploy.ps1 +1461b07a51d41c72be59b3903d36937d5912cadd600d8519142c47478d0d40c3 vendoo/scripts/github-deploy.sh +0a9bafc79c1c5d59fb9b51ebc34c00eec4a4e9a5e873ce1818f50f3ab983da70 vendoo/scripts/install-local-flux.ps1 +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 vendoo/scripts/runtime-start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 vendoo/scripts/runtime-stop-local-flux.ps1 +1a5736cf5fccc991df122163977c2eac80281bd99a54a5680b3d7eae99e4b79d vendoo/scripts/start-local-flux.bat +3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd vendoo/scripts/uninstall-local-flux.ps1 +aa58e3380d0e5c8e5df2bf7d09dea4cfca996ecc1991c11e2518e840f72b8bf2 vendoo/server.mjs +a7384ecbf3977245d31097ea4ff224142d998c3e80d0e44f49aaa8a2d4395728 vendoo/setup-gui.ps1 +cf4519ce5adc01f05458e3286e86f3a419cceaff5fcdd869a1d07f1c1d1a0d8c vendoo/setup.bat +5b3d8fb189b7efb656a64fc41c1bf0cde2202ac91aaefbea74c927acd9d9727a vendoo/setup.ps1 +bf3bf58517beaa27e837f9b5c75d0149b640bba8cf4df6f0a6b2566fd36d7193 vendoo/setup.sh +90485a16e3468cb9a6750646700fbb036cb9ac410e9b8bd5c582f7b923bf3c31 vendoo/tools/build-release.mjs +ac8440bce2659a035302609722f1dfed5d125223376cd575ffc0636397c94b54 vendoo/tools/generate-design-tokens.mjs +294659f2beb607540abbc3d6b4a1658525448a42f8880f2b282081a259c4e35b vendoo/tools/prepare-git-staging.mjs +8f9ede5f112ee03323db3a6c73aee00b7037f5139dad241b11baf5f0e9cee4e0 vendoo/tools/verify-config-store.mjs +5752f6edb956696781da8bddb1c1848d26941fbed5a524691653c84c88c34bff vendoo/tools/verify-db-migrations.py +3745de55a110d8254983b096c6afab196ddaec7f1153cd2f34c1e480b15aa33e vendoo/tools/verify-deployment-1.32.0.mjs +44f6b349b722e5a83d4725c4879fa1a9383f59ab5634c49d3d7d9d7aa0cd5e12 vendoo/tools/verify-deployment-1.33.0.mjs +cbd91b45d97e11bd4f5ca2f9d2edf2b5931cb2aa336cb71afc30aad9cffbe1e0 vendoo/tools/verify-deployment-1.34.0.mjs +01a43040a95cdac40703f49031acd9267cdba99d9027a51718f35ffb643237fc vendoo/tools/verify-deployment-1.34.2.mjs +906767e574c06aedebb2d7797150bda2f1e4c4536e8f82f4c7de2e3cc8398c8e vendoo/tools/verify-deployment-1.34.3.mjs +71d00227a59592cc0ad8ba321c63350ec25748ef965d8cd0eff1317738c7fba8 vendoo/tools/verify-deployment-1.35.0.mjs +b65eec7fd54e1459b363d99d20f9438ae5bc4b518e775de8b97e33e89a382e42 vendoo/tools/verify-deployment-1.36.0.mjs +1f052446f6c9627420afeb48aa09771b61f9e08ba573f6a0651b2843ef0b79f8 vendoo/tools/verify-git-safety-regression.mjs +5bba3d7ae042b0bcf4eb56bacb3f6e41e1b0e00cadd7666505792ba6748cc987 vendoo/tools/verify-git-safety.mjs +2f8d687319619240efe5849c276f7723e69755c66aa3b9609e41fdf0cda01e55 vendoo/tools/verify-git-staging.mjs +1d4351e4a827e718766a1677ddfbc3771a91942d0d5a93753e39f1b99c68a4dd vendoo/tools/verify-hotfix-1.23.2.mjs +44c95844422be3deaad6054ee3f0cced9c25986815d976e0c64b042d0c2c5e1e vendoo/tools/verify-installer-shell.sh +cab03e98687af87f359f6424d440fc0b644da97b3c614509b8c7d008d4b18a8d vendoo/tools/verify-platform-architecture-1.35.0.mjs +732bb15431746a829e45ae55c695360cbf1dd21d4fc3be26a98f35708d571e84 vendoo/tools/verify-platform-architecture-1.36.0.mjs +36e35fedb08923c22fd8551389d33432b66dbd4215d95c594ef29bfc790c98e0 vendoo/tools/verify-release-1.25.0.mjs +89621694b7bad18b6f3f1ac1956032f64112c40d3edc069ca581dc813d57d628 vendoo/tools/verify-release-1.25.1-static.mjs +c908f5186cf2d71ef83d5e649327ae19965568933acfb32057ca00d29cf890bc vendoo/tools/verify-release-1.25.1.mjs +e31a055d2ebb8e439f5b0e0235a71dacecb891e299377cfbbcfa8a32f53a85b0 vendoo/tools/verify-release-1.26.0-static.mjs +7e107b5f9a1b4ee381ff31105feb6d38dda5c40e4afe105a955badc36e6b8d10 vendoo/tools/verify-release-1.26.1-static.mjs +d9a75c45f40d857c84790d51dca2fd088c3851ca250405871a7e32773918c66e vendoo/tools/verify-release-1.27.0.mjs +3989e3ec236c1aa4c387b26cc15e04cf200a018e29c095321acf826c6bae4d66 vendoo/tools/verify-release-1.28.0.mjs +7d196d913a697248ab1233907fed56c10329cf201e3bdc173bca73cb02755369 vendoo/tools/verify-release-1.29.0.mjs +3f30260dabbdf0fa7b042381cb9e24f840d0c3ddbc44fe309763789a7d14afb7 vendoo/tools/verify-release-1.30.0.mjs +b1ceed5dfc17dbd3b038b7168e61076e4d39a0f1d67e2b87677a91c4dbb2daff vendoo/tools/verify-release-1.31.0.mjs +a372258b97aa68d59a1fe0fd3f380d019c59412d5c408a6064f437d5cfc6e709 vendoo/tools/verify-security-1.31.0.py +31a120622ce8bb8451014f2662366f81abd28d0f3be59adfe35d17e0ada1838f vendoo/tools/verify-slice30-mock.mjs +20957a4a038b9afdb01a8c98b881644daf793d2a6bb778a4c8e04780687d00e4 vendoo/tools/verify-slice31-image-editor.mjs +21061ed5dc88feef48e80efffd9a1054d38598dce5a5d3f9f6bde39b539b83f0 vendoo/tools/verify-slice32-batch.mjs +0f05b1ac3b686cf535bcfb3e6f6df24a29c3429ade1b3c666c1b99abb1a7311b vendoo/tools/verify-slice32-image-render.mjs +c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 vendoo/tools/verify-theme-manager-1.36.0.mjs +0f3f684019763a14f5fbaec42e42c680cf748d930d46cd607fc61eb413ca4553 vendoo/tools/verify-ui-contracts-1.26.1.mjs +d677548be9b02ebd3b53e93158aeb740a11bd3e6d52b07898f1908c74a22294f vendoo/update-manifest.json diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.dockerignore b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.dockerignore new file mode 100644 index 0000000..3035588 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.dockerignore @@ -0,0 +1,27 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +npm-debug.log* +*.log +*.db +*.db-* +*.sqlite +*.sqlite3 +backups +operations +logs +uploads +runtime +data +local-ai/comfyui +local-ai/downloads +local-ai/.env.local-flux +MockupDesign +*.zip +FILE_CHECKSUMS_SHA256.txt +release-output +.vendoo-github-state.json +operations/first-admin-code.txt diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/CODEOWNERS b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/CODEOWNERS new file mode 100644 index 0000000..5199ab5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Masterluke77 diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/dependabot.yml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/dependabot.yml new file mode 100644 index 0000000..876f32a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '05:00' + timezone: Europe/Berlin + open-pull-requests-limit: 5 + groups: + npm-production: + dependency-type: production + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + time: '05:30' + timezone: Europe/Berlin + open-pull-requests-limit: 5 + - package-ecosystem: docker + directory: / + schedule: + interval: monthly + time: '06:00' + timezone: Europe/Berlin + open-pull-requests-limit: 5 diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/pull_request_template.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/pull_request_template.md new file mode 100644 index 0000000..202b92f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Änderung + + + +## Sicherheit und Daten + +- [ ] Keine `.env`, Tokens, Datenbanken, Uploads, Backups, Logs oder Modelle enthalten +- [ ] Bestehende Benutzer- und Artikeldaten bleiben erhalten +- [ ] Originalbilder werden nicht überschrieben +- [ ] FASHN VTON / Virtual Try-on wurde nicht wieder eingeführt + +## Prüfung + +- [ ] `npm ci` +- [ ] `npm run verify:git` +- [ ] `npm run check` +- [ ] `npm run verify:architecture` +- [ ] `npm run verify:deployment` +- [ ] `npm run verify:db` +- [ ] Windows-/Docker-/Browser-Abnahme dokumentiert oder ausdrücklich als offen markiert diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/ci.yml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/ci.yml new file mode 100644 index 0000000..748cf56 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: Vendoo CI + +on: + push: + branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**'] + pull_request: + branches: [main, develop] + +permissions: + contents: read + +concurrency: + group: vendoo-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Syntax, Sicherheit und Release-Gates + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten reproduzierbar installieren + run: npm ci + + - name: Git-Sicherheitsgate + run: npm run verify:git + + - name: Git-Sicherheitsgate Regressionstest + run: npm run verify:git-regression + + - name: Git-Staging-Filter Regressionstest + run: npm run verify:git-staging + + - name: Syntaxprüfung + run: npm run check + + - name: Plattformarchitektur und Design Tokens prüfen + run: npm run verify:architecture + + - name: Theme Manager, Accessibility und Komponenten-Tokens prüfen + run: npm run verify:themes + + - name: Deployment-Gate + run: npm run verify:deployment + + - name: Persistenten Konfigurationsspeicher prüfen + run: npm run verify:config + + - name: Geführte Shell-Installer simulieren + run: npm run verify:installer-shell + + - name: Datenbankmigrationen prüfen + run: npm run verify:db + + - name: Docker-Compose-Dateien validieren + run: | + cp .env.example .env + python - <<'PY' + from pathlib import Path + p=Path('.env') + s=p.read_text() + s=s.replace('VENDOO_SETUP_TOKEN=', 'VENDOO_SETUP_TOKEN=ci-only-not-a-production-secret-1234567890') + s=s.replace('VENDOO_DOMAIN=', 'VENDOO_DOMAIN=vendoo.example.invalid') + p.write_text(s) + PY + docker compose -f compose.yaml config -q + docker compose -f compose.yaml -f compose.bind-mounts.example.yaml config -q + docker compose -f compose.vps.yaml config -q + rm -f .env + + - name: Docker-Image testweise bauen + run: docker build --pull --tag vendoo:ci . diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/release.yml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/release.yml new file mode 100644 index 0000000..2562b54 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.github/workflows/release.yml @@ -0,0 +1,143 @@ +name: Vendoo Release + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: vendoo-release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: masterluke77/vendoo + +jobs: + release-assets: + name: Release-Paket und Prüfsummen + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten installieren + run: npm ci + + - name: Release-Tag gegen Paketversion prüfen + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + EXPECTED="v$(node -p "require('./package.json').version")" + test "$RELEASE_TAG" = "$EXPECTED" || { echo "Release-Tag $RELEASE_TAG passt nicht zu $EXPECTED." >&2; exit 1; } + + - name: Vollständige Prüfung + run: | + npm run verify:git + npm run verify:git-regression + npm run verify:git-staging + npm run check + npm run verify:architecture + npm run verify:themes + npm run verify:deployment + npm run verify:config + npm run verify:installer-shell + npm run verify:db + + - name: Release-Staging erzeugen + run: node tools/build-release.mjs + + - name: ZIP und SHA-256 erzeugen + id: package + shell: bash + run: | + PACKAGE_NAME="$(cat release-output/RELEASE_NAME.txt)" + cd release-output + zip -r -9 "${PACKAGE_NAME}.zip" "${PACKAGE_NAME}" + sha256sum "${PACKAGE_NAME}.zip" > "${PACKAGE_NAME}.zip.sha256.txt" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Assets an GitHub Release anhängen + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + PACKAGE_NAME: ${{ steps.package.outputs.package_name }} + run: | + gh release upload "$TAG" \ + "release-output/${PACKAGE_NAME}.zip" \ + "release-output/${PACKAGE_NAME}.zip.sha256.txt" \ + --clobber + + - name: Build-Artefakte sichern + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.package_name }} + path: | + release-output/${{ steps.package.outputs.package_name }}.zip + release-output/${{ steps.package.outputs.package_name }}.zip.sha256.txt + if-no-files-found: error + retention-days: 30 + + container: + name: GHCR-Container veröffentlichen + permissions: + contents: read + packages: write + attestations: write + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + + - name: Docker Buildx aktivieren + uses: docker/setup-buildx-action@v3 + + - name: Bei GHCR anmelden + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Container-Metadaten erzeugen + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ github.event.release.tag_name }} + type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }} + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} + + - name: Container bauen und veröffentlichen + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + provenance: true + sbom: true + + - name: Container-Provenienz attestieren + uses: actions/attest@v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.gitignore b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.gitignore new file mode 100644 index 0000000..df9e144 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.gitignore @@ -0,0 +1,84 @@ +# Secrets and local configuration +.env +.env.* +!.env.example +!.env.docker.example +*.pem +*.key +*.p12 +*.pfx +*.crt + +# Dependencies +node_modules/ + +# SQLite runtime data +db/*.db +db/*.db-shm +db/*.db-wal +db/*.sqlite +db/*.sqlite3 + +# User and runtime data +uploads/* +!uploads/.gitkeep +backups/ +logs/ +tmp/ +temp/ +sessions/ +data/ +docker-data/ +*.log + +# OS and editor files +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ + +# Local overrides +compose.override.yaml +docker-compose.override.yml + +# Local AI runtimes and downloaded model data +local-ai/comfyui/ +local-ai/downloads/ +local-ai/extract-temp/ +local-ai/fashn-vton/.venv/ +local-ai/fashn-vton/engine/ +local-ai/fashn-vton/weights/ +local-ai/fashn-vton/outputs/ +local-ai/fashn-vton/runtime/ +local-ai/fashn-vton/hf-cache/ +local-ai/fashn-vton/fashn-vton-*.zip +local-ai/fashn-vton/python311/ +local-ai/fashn-vton/uv/ +local-ai/fashn-vton/uv-cache/ +local-ai/fashn-vton/downloads/ + +# Local update, rollback and slice archives +updates/ + +# Operations Center runtime +operations/staging/ +operations/updates/ +operations/rollback/ +operations/apply-*/ +operations/pending-operation.json +operations/last-operation.json + +# Deployment assistant state +.vendoo-install-mode +runtime/ +# Generated deployment and release output +release-output/ +*.release.zip +*.release.sha256.txt +.vendoo-github-state.json + +# First-run and local installer state +operations/first-admin-code.txt +config/local/ + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.npmrc b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.npmrc new file mode 100644 index 0000000..a9758f6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/.npmrc @@ -0,0 +1,9 @@ +registry=https://registry.npmjs.org/ +audit=false +fund=false +strict-ssl=true +prefer-offline=true +fetch-retries=5 +fetch-retry-factor=2 +fetch-retry-mintimeout=10000 +fetch-retry-maxtimeout=120000 diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CHANGELOG.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CHANGELOG.md new file mode 100644 index 0000000..b643c2e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CHANGELOG.md @@ -0,0 +1,710 @@ +# Changelog + +## 1.36.0 – Secure Theme Manager, Accessibility & Component Token Foundation + +### Neu + +- `vendoo.themes` als erstes natives Fachmodul auf dem Plattformkernel. +- Persönliche Theme-Präferenzen pro Benutzer mit Theme, UI-Dichte und reduzierter Bewegung. +- Sichere Theme-Profile für Administratoren mit Basis-Theme, Token-Overrides, Import, Export und Systemstandard. +- Live-Vorschau im Browser; Speichern erst nach expliziter Bestätigung. +- Serverseitige Validierung aller Theme-Werte und WCAG-orientierte Kontrastprüfung zentraler Text-/Oberflächenpaare. +- Light, Dark, System und High Contrast als integrierte Modi. +- 42 typisierte Design Tokens, darunter erste Komponenten-, Fokus- und Touch-Target-Tokens. +- Tokenisierte Buttons, Eingabefelder, Karten, Dialoge und Fokuszustände. +- Deny-by-default API-Verträge für Theme-Routen sowie getrennte Benutzer- und Administrator-Policies. +- Audit-Protokoll für persönliche Präferenzen, Theme-Profile und Systemstandard. +- Eigenes `verify:themes`-Gate in lokaler Prüfung, GitHub CI und Release-Workflow. +- GitHub Draft Pull Request #9 für `release/1.36.0-theme-manager`. + +### Sicherheit und Kompatibilität + +- Keine freie CSS-, JavaScript-, URL- oder Dateipfad-Eingabe. +- Unbekannte Tokens und Theme-IDs werden abgewiesen. +- Normale Benutzer dürfen ausschließlich ihre eigene Darstellung ändern. +- Systemstandard und benutzerdefinierte Profile erfordern administrative Berechtigung. +- Bestehende Daten, Benutzer, Artikel, Uploads, Backups und FLUX-Daten bleiben erhalten. +- Kein Reset, keine Framework-Migration und keine Datenbankablösung. + +## 1.35.0 – Modular Platform Kernel, Design-System Contracts & Security Architecture Foundation + +### Neu + +- Modularer Plattformkernel mit Lifecycle, Event Bus, Feature Flags, Modul- und Policy-Registry. +- Zwölf bestehende Fachbereiche als validierte `legacy-bridge`-Module mit expliziten Abhängigkeiten, Capabilities und Ownership. +- Deny-by-default Route Registry: neue Kernel-Routen benötigen Besitzer und explizite Policy. +- Administrativ geschützte Plattform-, Modul- und Theme-Vertragsendpunkte. +- Kernel-Healthcheck in `/readyz` und geordneter Kernel-Shutdown. +- Versionierte Design-Token-Quelle mit 33 Tokens für Light, Dark und High Contrast. +- Deterministischer Token-Generator und browserseitige Theme Runtime. +- Sichere Theme-Definitionen: nur freigegebene Farb- und Dimensionswerte, kein freies CSS/JS. +- Modul-JSON-Schema, Architekturgrenzen, Migrationsroadmap und Security-Architekturdokumentation. +- Neues `npm run verify:architecture` mit Zyklus-, Policy-, Event-, Theme- und Token-Regressionstests. +- GitHub Draft Pull Request #7 für `release/1.35.0-platform-kernel`. + +### Kompatibilität + +- Kein Reset, keine Framework-Migration, keine Datenbankablösung. +- Windows-Lokalbetrieb, Docker, NAS, VPS, Zero-Edit-Installer und Git-Sicherheitsgate bleiben erhalten. +- Bestehende CSS-Variablen bleiben über eine Token-Kompatibilitätsbrücke funktionsfähig. +- Bestehende Routen werden in 1.35.0 noch nicht massenhaft verschoben; die Migration erfolgt fachmodulweise. + +## 1.34.3 – Git-Staging Update-Archiv-Ausschluss Hotfix + +### Behoben +- Der lokale Ordner `updates/` wird als Update-, Rollback- und Slice-Artefaktspeicher behandelt und nicht nach GitHub oder in Release-Staging-Verzeichnisse übernommen. +- Alte Sicherungskopien wie `updates/backups/.../setup.bat`, `setup.ps1` oder `scripts/install-local-flux.ps1` lösen das Git-Sicherheitsgate nicht mehr aus, weil sie bereits vor dem Scan ausgeschlossen werden. +- Die aktiven Installationsdateien im Projektroot und unter `scripts/` bleiben Teil des Quellimports. +- Das Git-Sicherheitsgate blockiert `updates/` weiterhin, falls ein fremder Kopierweg den Ordner dennoch in das Staging einschleust. +- `.gitignore` schützt den lokalen Updateverlauf zusätzlich vor versehentlichen Commits. + +### Qualitätssicherung +- Regressionstest mit den drei konkret gemeldeten Pfaden aus `slice22_1_20260707-194953`. +- Release-Builder und GitHub-Deploy-Assistent verwenden weiterhin dieselbe Filterfunktion. +- Neuer Importbranch `release/1.34.3-initial-import` und Draft Pull Request #6. + +## 1.34.2 – Git-Sicherheitsdiagnose & gemeinsamer Staging-Builder + +### Behoben +- Die Meldung `Git-Sicherheitsgate fehlgeschlagen (4)` wird nicht mehr ohne Detailzeilen ausgegeben. Der Windows-Assistent fängt stdout und stderr getrennt ab und zeigt jeden konkreten Treffer an. +- Der vollständige Fehlerbericht wird unter `logs/github-deploy-safety-report.txt` gespeichert und unter Windows automatisch im Editor geöffnet. +- Windows, Linux und macOS verwenden denselben Node-basierten Staging-Builder statt unterschiedlicher Robocopy-/rsync-Regeln. +- Lokale `.env.*`-Dateien, Operations-Daten, Uploads, Backups, Logs, Datenbanken, Schlüsseldateien und FLUX-/ComfyUI-Laufzeitdaten werden vor dem Sicherheitsgate automatisch aus dem temporären Git-Staging ausgeschlossen. +- `.env.example` und `.env.docker.example` bleiben als sichere Vorlagen enthalten. +- Der Staging-Builder leert nur den Arbeitsbaum des temporären Klons und erhält dessen `.git`-Verzeichnis vollständig. + +### Qualitätssicherung +- Neuer plattformübergreifender Git-Staging-Regressionstest. +- Der Test belegt, dass lokale Secrets und Laufzeitdaten ausgeschlossen, öffentliche Vorlagen übernommen und `.git` nicht verändert werden. +- CI und Release-Workflow führen den Staging-Regressionstest zusätzlich zum Secret-Regressionstest aus. +- Neuer Importbranch `release/1.34.2-initial-import` und Draft Pull Request #5. + +## 1.34.1 – Git-Sicherheitsgate Windows Hotfix + +### Behoben +- Windows-Pfade werden im Git-Sicherheitsgate mit `fileURLToPath(import.meta.url)` in echte Dateisystempfade umgewandelt. +- Der Sicherheitscheck scannt den vollständigen Import-Arbeitsbaum und nicht nur bereits von Git verfolgte Dateien. +- Noch unversionierte `.env.local`, `.env.production`, Token-Dateien und sonstige Secrets werden vor `git add` erkannt. +- Node.js ist für den sicheren Import verpflichtend; ein stilles Überspringen des erweiterten Scans ist nicht möglich. + +## 1.34.0 – GitHub CI/CD & Zero-Edit Installation Foundation + +### Installation +- Grafischer Windows-Installationsassistent für lokalen Node.js-Betrieb und Docker Desktop. +- Geführte Linux-/NAS-/VPS-Konfiguration ohne manuelles Bearbeiten von `.env` oder Compose-Dateien. +- Automatische Erzeugung und sichere Ablage des Erst-Admin-Setup-Codes. +- VPS-Modus mit integriertem Caddy-Reverse-Proxy und automatischem HTTPS. +- Konfiguration kann später über den Assistenten geändert werden, ohne bestehende Daten zu löschen. +- Persistenter Runtime-Konfigurationsspeicher für Eingabemasken im gehärteten read-only Container. +- Simulierbares Shell-Installer-Gate für Docker-, NAS- und VPS-Modus; dabei wurde die Standardwert-Abfrage gegen Prompt-Vermischung gehärtet. + +### GitHub und Releases +- Git-Sicherheitsgate gegen Secrets, Datenbanken, Uploads, Backups, Logs, Runtime- und Modellordner. +- GitHub Actions für CI, Docker-Build, Release-Pakete, SHA-256 und GHCR-Images. +- Windows- und Shell-GitHub-Deploy-Assistent für kontrollierten Erstimport auf einen Release-Branch. +- Privater Branch `release/1.34.0-initial-import` und Draft Pull Request #3 real vorbereitet. +- Release-Pakete werden aus einem bereinigten Staging-Verzeichnis erzeugt und nicht als Binärdateien in die Git-Historie aufgenommen. + +### Sicherheit +- GitHub Actions besitzen jobbezogen minimale Berechtigungen. +- GHCR-Images erhalten eine Provenienz-Attestierung auf Basis des tatsächlich veröffentlichten Image-Digests. +- Container-Registry-Veröffentlichung erfolgt nur bei veröffentlichten GitHub Releases und passendem Versionstag. +- Produktions-Secrets bleiben in `.env` beziehungsweise GitHub Environments und niemals im Repository. + +## 1.33.0 – Multiplatform Deployment Wizard & Flux/Admin Fixes + +- Installationsziel-Assistent für Windows lokal, Docker Desktop, NAS/Portainer und VPS/Linux +- neues `setup.sh` für Linux-, NAS- und VPS-Zielsysteme +- Installationsmodus wird für Start, Update, Reparatur, Status und Deinstallation gespeichert +- Docker-Host-Gateway für einen getrennten FLUX/ComfyUI-Dienst +- FLUX Studio startet ohne automatische Bildauswahl +- Reset-Button leert den Arbeitsbereich, ohne Verlauf, Bilder oder Jobs zu löschen +- Benutzerbearbeitung auf stabile Event-Listener und Cachelogik umgestellt +- Passwortvalidierung vor dem Update und sanitisierte Benutzerantwort + +## 1.32.0 – Docker, NAS & Production Deployment Foundation + +- produktionsgeeignetes, mehrstufiges Dockerfile mit Lockfile-Installation +- Compose mit getrennten persistenten Volumes, non-root, read-only und Healthcheck +- zentrale, konfigurierbare DB-/Upload-/Backup-/Operations-/Log-Pfade bei voller Windows-Kompatibilität +- `/readyz` mit SQLite- und Schreibpfadprüfung +- geordneter SIGTERM-/SIGINT-Shutdown mit WAL-Checkpoint +- geschütztes Erst-Admin-Setup über `VENDOO_SETUP_TOKEN`; übrige API bleibt bis zum Setup gesperrt +- konfigurierbare Proxy- und Cookie-Regeln +- produktive Allowlist für Browser-Extension-Origins +- Uploads nur angemeldet abrufbar und strengere Dateitypprüfung +- NAS-, Portainer-, Backup-, Restore-, Update- und Rollback-Dokumentation +- FLUX/ComfyUI bleibt getrennt und ist im Hauptcontainer standardmäßig deaktiviert + +## 1.31.0 – Mehrbenutzer- und Server-Sicherheitsgrundlage + +- Fünf feste Rollen mit serverseitig erzwungenen Berechtigungen: Administrator, Manager, Editor, Publisher und Leser. +- Sichere Sitzungen mit absoluter und inaktiver Laufzeit, Widerruf, Benutzer-Sitzungsverwaltung und Login-Sperre. +- Bearbeitungssperren für Artikel mit automatischer Verlängerung und kontrollierter Freigabe. +- Filterbares und exportierbares Audit-Protokoll. +- Rate Limits, Host-/Origin-Freigaben, Request-IDs, CSP/Helmet und standardmäßige Bindung an localhost. +- Benutzer- und Sicherheitsoberfläche mit Rollenmatrix, aktiven Sperren und Sicherheitsstatus. +- Additive Migrationen; bestehende Artikel, Datenbank, Bilder und Einstellungen bleiben erhalten. + +## 1.30.0 – Operations Center: Backup, Restore, Updates & Extension-Diagnose + +- Neue Betriebszentrale unter Admin → System mit Backup-, Update- und Extension-Diagnose-Tabs. +- Konsistente SQLite-Backups über die Backup-API statt Kopieren der geöffneten Datenbank. +- Vollbackups mit Uploads, optionaler `.env`, Manifest, SHA-256-Dateiprüfsummen und SQLite-Integritätsprüfung. +- Tägliche rotierende automatische Backups mit konfigurierbarer Uhrzeit, Aufbewahrung und Upload-Option. +- Verifizierte Wiederherstellung mit automatischem Sicherheitsbackup und bewusstem Offline-Anwenden. +- Update Center prüft Version, Pflichtdateien und verbotene Laufzeitdaten, bevor ein Paket vorbereitet wird. +- Setup-Menüpunkt 11 wendet vorbereitete Update-/Restore-Operationen an; Menüpunkt 12 führt den Rollback aus. +- Migrationsstatus und letzte Systemoperation sind in der Oberfläche sichtbar. +- Vendoo Link 1.9.0 sendet Diagnose-Heartbeats; Chrome, Edge, Firefox und Safari werden getrennt ausgewertet. +- Keine Datenbank, Uploads, `.env`, API-Schlüssel oder Modelle werden durch ein Update-Paket gelöscht. + +## 1.29.0 – Slice 34: Publishing Hardening + +- plattformübergreifende persistente Publishing-Queue +- Doppelveröffentlichungsschutz und Idempotenzprüfung +- automatische Retry-Strategie und Neustart-Wiederaufnahme +- Fehlerzentrale, Ereignisprotokoll, externe IDs und Links +- eBay Sync, Update und Beenden über offizielle Inventory API +- Etsy Statusabgleich +- Extension-Aufträge für Vinted und Kleinanzeigen +- Scheduler an die gehärtete Queue angebunden +- Vendoo Link 1.8.0 + +## 1.28.0 – Slice 33: Artikel Quality Center + +- Neuer Hauptmenüpunkt **Qualitätscenter** mit Score, Noten A–E, Blockern, Warnungen und Empfehlungen. +- Serverseitige Prüfung von Titel, Beschreibung, Pflichtfeldern, Preis, Organisation, Suchbegriffen und plattformbezogenen Vendoo-Profilen. +- Vollständige Bildprüfung mit Dateiexistenz, Lesbarkeit, Abmessungen, Format und Auflösungswarnungen. +- Erkennung möglicher Duplikate über gleiche SKU und Titelähnlichkeit. +- Plattformbereitschaft, Gebühren und geschätzter Erlös gemeinsam im Prüfbericht. +- Persistenter Score-Verlauf je Artikel mit Vergleich zur vorherigen Analyse. +- Filter, Suche und Pagination für bereite, blockierte und zu prüfende Artikel. +- AI-Verbesserungen für Titel, Beschreibung und Tags über den aktuell aktiven Text-AI-Provider. +- AI erzeugt ausschließlich Vorschläge; Änderungen werden erst nach sichtbarem Vorher-/Nachher-Vergleich und ausdrücklicher Bestätigung gespeichert. +- Keine automatische Erfindung von Produktmerkmalen, Marken, Schäden oder Lieferumfang. + +## 1.27.0 – Slice 32: Batch Image Factory + +- Neuer Hauptmenüpunkt **Bildproduktion** als persistente Batch Image Factory. +- Mehrere Quellbilder direkt aus der zentralen Bildergalerie auswählen oder aus der Galerie übergeben. +- Acht integrierte Produktionsprofile für eBay, Vinted, Kleinanzeigen, Etsy, Instagram, Webshop und optimierte Originale. +- Eigene Produktionsprofile mit Zielgröße, Anpassung, Hintergrund, Format, Qualität und Dateinamensschema anlegen, bearbeiten und löschen. +- Mehrere Profile pro Auftrag erzeugen mehrere Ausgaben je Originalbild. +- Optionale Hintergrundentfernung mit Intensität sowie Wasserzeichen mit Text, Position und Deckkraft. +- Persistentes Job-Center mit 5/10/15 Aufträgen pro Seite, Pause, Fortsetzen, Abbruch, Gesamt-Retry und Einzel-Retry. +- Neustart-Wiederaufnahme für wartende und laufende Aufträge; pausierte Aufträge bleiben pausiert. +- Ergebnisse werden nicht destruktiv als neue Bildversionen unter `uploads/batch/` gespeichert und erscheinen automatisch in der Bildergalerie. +- Fertige Ergebnisse eines Auftrags können gesammelt als ZIP heruntergeladen werden. +- Bildrenderer um `contain`, `cover`, exakte Ziel-Canvas, Ausgabeordner und kontrollierte Dateinamen erweitert. + +## 1.26.1 – Stabilitäts-, Responsive- und Regression-Gate + +- Zentrale Systemprüfung im Adminbereich ergänzt: SQLite-Integrität, Schreibrechte, UI-Dateien, FLUX-Workflow, Browser-Extensions, AI-Konfiguration und optionale ComfyUI-Erreichbarkeit. +- Öffentlichen, geheimnisfreien `/healthz`-Endpunkt für lokale Verbindungsüberwachung ergänzt. +- API-Client um Request-IDs, definierte Zeitlimits, einmaligen GET-Retry bei Netzwerk-/Serverfehlern und strukturierte Fehlerprotokollierung erweitert. +- Sichtbare Verbindungsleiste bei Serverausfall mit automatischer Wiederholungsprüfung ergänzt. +- Unbehandelte JavaScript- und Promise-Fehler werden lokal mit Build, Seite und Request-ID für die Diagnose erfasst. +- Responsive-Verhalten auf die tatsächlich verfügbare Workspace-Breite umgestellt; `ResizeObserver` vergibt Breitenklassen unabhängig von der Browserbreite. +- Globale Schutzregeln gegen horizontales Überlaufen von Formularen, Bildern, Vorschauen, Werkzeugleisten und langen Inhalten ergänzt. +- Neue statische Release- und UI-Vertragsprüfungen verhindern doppelte IDs, tote Navigation, fehlende Diagnoseelemente und veraltete Buildmarker. +- Keine Datenbankstruktur, Nutzerdaten, API-Schlüssel, Extensions oder FLUX-Modelle verändert. + +## 1.26.0 – Artikel-Navigation, Publish-Reparatur, Zoom-Galerie & Extension Center + +- „Neues Listing“ konsequent in „Neuer Artikel“ und „Listing generieren“ in „Artikel generieren“ umbenannt. +- „Neuer Artikel“ als eigener Hauptmenüpunkt direkt unter dem Dashboard ergänzt; „Listings“ heißt in der Navigation jetzt „Artikel“. +- Publish-Datenaufbereitung gegen fehlerhafte ältere JSON-Felder gehärtet und einen Scope-Fehler im direkten Publish-Workspace behoben. +- Smart Copy öffnet wieder zuverlässig als Popup; die mittlere Publish-Fläche zeigt Titel, Beschreibung, Tags, Metadaten und Fotos. +- Medienvorschau um Fit-, 100-%-, Stufen- und Mausrad-Zoom sowie Verschieben bei Vergrößerung erweitert. +- Galerie-Auswahl und Favoriten auf kleine, konsistente Lucide-basierte SVG-Icons umgestellt. +- Gebührenrechner für private Verkäufer aktualisiert: eBay.de, Vinted und normaler Kleinanzeigen-Privatverkauf ohne Verkäuferprovision; Etsy separat als Shop-Kalkulation. +- Neuer Menüpunkt „Extensions“ mit Browsererkennung, Ein-Klick-Vorbereitung, Pfadkopie und Downloadpaketen für Chrome, Edge, Firefox und Safari. +- Safari-WebExtension-Quelle samt Xcode-Konvertierungsskript ergänzt. +- Doppelte Settings-ID und doppelten Galerie-Event-Listener bereinigt. + +## 1.25.1 – Adaptive Bildergalerie + +- Galerie-Raster vollständig auf automatische Kartenbreiten mit `auto-fit` und Container Queries umgestellt. +- Die gesamte Bildfläche öffnet jetzt die Lightbox; die übergroße Vorschau-/Öffnen-Schaltfläche wurde entfernt. +- Auswahl und Favorit skalieren mit der Kartenbreite und bleiben sauber in den Bildecken ausgerichtet. +- Aktionsleiste auf vier kompakte, adaptive Bildaktionen umgestellt; auf sehr schmalen Karten werden nur Symbole gezeigt. +- Auswahlstatus wird zusätzlich an Karte und Bildfläche sichtbar, ohne das Motiv zu verdecken. +- Seitennavigation mit direkter Seitenauswahl und Bildbereich wird oberhalb und unterhalb der Galerie angezeigt. +- Listenansicht und schmale Bildschirmbreiten wurden im selben Komponentenmodell neu gehärtet. + +## 1.25.0 – Responsive Detail Editor, Gallery UX, Smart Copy Modal & FLUX Prompt Lab + +- Listing-Bearbeiten reagiert jetzt auf die tatsächlich verfügbare Inhaltsbreite per Container Queries und bleibt auch mit Sidebar, Quill, Vorschau und Kontextkarten ohne horizontalen Überlauf bedienbar. +- Die zentrale Bildergalerie erhielt sauber ausgerichtete Auswahlfelder, Favoriten-Overlay, eine eindeutige Vorschauaktion, zweizeilige Titel und ein stabiles 2×2-Aktionsraster. +- Publish-Aktualisieren rotiert nicht mehr; während des Ladens werden ruhige Textzustände und `aria-busy` verwendet. +- Smart Copy öffnet wieder als eigenes responsives Arbeitsfenster mit Kopierfeldern, Fotos, Plattformstart und Veröffentlichungsstatus. +- FLUX Ergebnisse & Favoriten besitzen Pagination mit 5, 10 oder 15 Bildern pro Seite. +- Das FLUX Job-Center ist auf 5, 10 oder 15 Aufträge pro Seite begrenzt. +- Neues FLUX Prompt Lab: lokale Strukturierung oder AI-Veredelung über den aktuell aktiven Claude-, OpenAI-, OpenRouter- oder Ollama-Provider. +- Prompt Lab schützt auf Wunsch Motivmerkmale und vermeidet erfundene Texte, Logos und Wasserzeichen. +- Zusätzliche Breitenhärtung für Publish, Medienbibliothek, FLUX, Einstellungen und Generator. + +## 1.24.0 – Responsive UI, FLUX-Auftragsarchiv & zentrale Bildergalerie + +- Listing-Seite auf schmalen Breiten von einer starren Mindestbreite auf echte responsive Kartenzeilen umgestellt. +- Responsive Härtung für Filter, Batch-Aktionen, FLUX-Job-Center, Formulare, Aktionsleisten und Medienverwaltung ergänzt. +- Hintergrundentfernung im Advanced Editor um eine regelbare Intensität von 0 bis 100 erweitert. +- FLUX-Aufträge können einzeln oder gesammelt archiviert und aus dem Archiv wiederhergestellt werden. +- Im FLUX-Archiv können einzelne, mehrere oder alle Aufträge endgültig gelöscht werden; erzeugte Bilder bleiben erhalten. +- Serverseitige Pagination und Seitengröße für aktive und archivierte FLUX-Aufträge ergänzt. +- Neuer Navigationsbereich „Bilder“ als zentrale Galerie für FLUX-, bearbeitete, AI- und Listing-Bilder. +- Galerie mit Raster-/Listenansicht, Suche, Quellenfiltern, Favoriten, Sortierung, Pagination, Mehrfachauswahl, ZIP-Download, Lightbox, Editor-Übergabe und geschützter Löschung aktiver Listing-Bilder. +- Vinted-Titelfeldschutz aus Vendoo Link 1.6.1 unverändert beibehalten. + +## 1.23.2 – Slice 31.2 Hotfix: FLUX-Layout, Editor-Werkzeuge & kompakte Aufträge + +- „System & ComfyUI“ aus der angehefteten FLUX-Seitenspalte entfernt und direkt unter „Prompt & Auftrag“ eingeordnet. +- FLUX Job-Center als Ganzes ein- und ausklappbar gemacht. +- Einzelne FLUX-Aufträge kompakt auf- und zuklappbar; aktive Aufträge sind standardmäßig geöffnet, abgeschlossene kompakt geschlossen. +- „Alle einklappen“ ergänzt, damit lange Verläufe die Seite nicht unnötig vergrößern. +- Separate Buttons für Hintergrundentfernung und Wasserzeichen aus Generator und Bildleisten entfernt. +- Hintergrundentfernung in den Advanced Image Editor integriert und als nicht destruktive Bildversion gespeichert. +- Wasserzeichen mit eigenem Text, fünf Positionen und einstellbarer Deckkraft in den Advanced Image Editor integriert. +- Editor-Werkzeuge gelten einheitlich für Generator-, Listing- und FLUX-Bilder. +- Vendoo Link 1.6.1 und der Vinted-Titelfeldschutz bleiben unverändert erhalten. + +## 1.23.1 – Slice 31.1 Hotfix: Editor-Sichtbarkeit & Vinted-Feldschutz + +- Advanced Image Editor erhält im Generator einen deutlich sichtbaren eigenen Einstieg. +- `app.js` und `style.css` werden versionsgebunden geladen; HTML/JS/CSS werden vom Vendoo-Server nicht mehr aus veraltetem Browser-Cache ausgeliefert. +- Der Windows-Starter öffnet Vendoo mit Build-Kennung. +- Vinted-Auto-Fill ordnet Felder nur noch über eindeutige Selektoren bzw. tatsächlich zugeordnete Labels zu. +- Zustands-, Marken-, Größen- und Farbauswahl dürfen das Titelfeld nicht mehr als Fallback verwenden. +- Vendoo Link auf Version 1.6.1 angehoben. + +# Changelog + +## 1.23.0 – Slice 31: Advanced Image Editor + +- Bestehenden einfachen Fotoeditor durch einen nicht destruktiven Produkt- und FLUX-Bildeditor ersetzt. +- Undo/Redo und eine vollständige Bearbeitungshistorie innerhalb der aktuellen Sitzung ergänzt. +- Persistente Bildversionen in `image_edit_versions` eingeführt; das Original bleibt unverändert und jederzeit wiederherstellbar. +- Vorher-/Nachher-Ansicht und Auswahl älterer Bearbeitungsversionen integriert. +- Freien Zuschnitt sowie 1:1, 4:5, 3:4, 16:9 und Original-Seitenverhältnis ergänzt. +- Drehen, horizontales/vertikales Spiegeln und stufenloses Begradigen umgesetzt. +- Belichtung, Helligkeit, Kontrast, Lichter, Schatten, Temperatur, Sättigung und Schärfe ergänzt. +- Export als JPEG, WebP oder PNG mit Qualitätsregler und Zielgröße umgesetzt. +- Bearbeitungen werden serverseitig mit `sharp` in voller Auflösung gerendert und immer als neue Datei gespeichert. +- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor bleiben bewusst außerhalb dieses Slices; es gibt keine Platzhalterbuttons. + +## 1.22.0 – Slice 30: FLUX Studio Pro + +- Persistentes FLUX-Job-Center mit SQLite-Aufträgen und einzelnen Varianten eingeführt. +- 1, 2 oder 4 Varianten, eindeutige Seeds und reproduzierbarer Seed Lock ergänzt. +- „Ähnlich erneut erzeugen“, Favoriten und vollständige Prompt-/Parameterhistorie umgesetzt. +- Abbruch, Wiederholen, Queue-Bereinigung und Neustart-Wiederaufnahme ergänzt. +- ComfyUI-Warteschlange, Historie, System-, GPU- und VRAM-Daten angebunden. +- Fortschrittsanzeige des FLUX Studios auf reale Phasen ohne geschätzte Prozentwerte umgestellt. +- Freier FLUX-Promptworkflow bleibt ohne Produktreferenz und ohne `LoadImage`. +- FASHN VTON und Virtual Try-on bleiben vollständig entfernt. + +## 1.21.0 – Slice 29: FLUX Quality & Settings Tabs + +- Einstellungsbereiche als horizontale Top-Tabs neu gestaltet. +- FLUX Studio mit Prompt-Check, lokalem Prompt-Assistenten und Qualitätsprofilen erweitert. +- FLUX.1-schnell standardmäßig auf vier Schritte optimiert; Detailprofil mit sechs Schritten. +- Negative Prompt im reinen FLUX-Promptmodus entfernt. +- Startprofile Auto, Schnell, Ausgewogen und Wenig VRAM ergänzt. +- ComfyUI-Start verwendet je nach Profil Cache-/VRAM- und optionale Fast-Flags. +- Laufzeit und final strukturierter Prompt werden je Bild gespeichert. +- Vorschau und Bearbeitungsaktionen visuell aufgewertet. + +## 1.20.1 – FLUX Runtime Startdiagnose und Health-Check + +- FLUX/ComfyUI-Start wartet jetzt bis zu 180 Sekunden auf den echten `/system_stats`-Health-Check statt nach 2,5–5 Sekunden vorschnell „gestoppt“ zu melden. +- Eigene Runtime-Statusdatei mit PID, Laufzeit, Startphase sowie bereinigten stdout-/stderr-Auszügen. +- ANSI-Farbcodes werden aus den angezeigten Runtime-Logs entfernt. +- Prozessabbruch während des Starts wird sofort erkannt und mit den letzten relevanten Logzeilen gemeldet. +- Das reine Vendoo-FLUX-Studio startet ComfyUI ohne benutzerdefinierte Nodes; der verwendete Workflow benötigt ausschließlich eingebaute Standard-Nodes. +- Start- und Stopp-Skripte liegen jetzt als feste Paketdateien vor und werden beim Drüberkopieren sofort aktualisiert. +- FLUX Studio und Einstellungen zeigen während des Starts laufend „Wird gestartet“ statt einen falschen Stoppstatus. + +## 1.20.0 – FASHN/VTO entfernt und eigenständiges FLUX Studio + +- FASHN VTON, Modelbibliothek, lokale VTO-Dienste, API-Routen, UI, Installationsskripte und Einstellungen vollständig aus dem aktiven Produkt entfernt. +- Bestehende VTO-Runtime, Autostarts, Logs und alte VTO-Einstellungen werden beim ersten Setup-Lauf entfernt; eigene alte Modelbilder werden nach `uploads/ai-generated/legacy-models` gesichert. +- Neuer Hauptmenüpunkt **FLUX Studio** mit freiem Prompt, Stil, Format, Seed und Schritten. +- Live-Status und Start/Stopp-Steuerung für das lokale ComfyUI/FLUX. +- Vorschau, Download, Bildbibliothek, Löschen und Übernahme in den Listing-Generator. +- Nachträglicher Bildeditor für Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung. Bearbeitungen werden als neue Version gespeichert. +- Neue Tabelle `flux_images`; aufgegebene VTO-Tabelle und `ai_vto_*`-Einstellungen werden aus bestehenden Datenbanken entfernt. +- FLUX bleibt ein reiner Prompt-Bildgenerator; Ghost Mannequin und Flatlay bleiben als separate Produktbild-Werkzeuge bestehen. + +## [1.15.0] - 2026-07-07 + +### Neu + +- Komplett neu strukturierte Einstellungszentrale mit Bereichen für Allgemein, AI & APIs, Marktplätze und lokales FLUX. +- Großer FLUX-Live-Status mit klarer Anzeige für Aktiv, Gestoppt, Deaktiviert und Nicht installiert. +- Lokales FLUX kann direkt aktiviert, deaktiviert, gestartet, gestoppt, repariert, aktualisiert und sicher deinstalliert werden. +- Sichere FLUX-Deinstallation entfernt ComfyUI und das Modell, behält standardmäßig aber die Installationsarchive für eine schnelle Neuinstallation. +- Einstellbare Produktreferenz-Treue für AI-Model-Fotos. +- Neuer Geschwindigkeitsmodus mit 768 × 768 Pixeln als Standard; 1024 × 1024 bleibt als Qualitätsoption verfügbar. + +### Behoben + +- Produktfotos werden bei lokalem FLUX jetzt tatsächlich an ComfyUI übertragen und als Image-to-Image-Referenz in den Workflow eingebunden. +- Hoodie, Farbe, Kapuze, Reißverschluss, Aufdrucke, Logos und Schnitt werden im Prompt zusätzlich als verbindliche Produktmerkmale geschützt. +- AI-Bildjobs aktualisieren ihren Fortschritt jetzt während Vorbereitung, Upload, Rendering, Sicherheitsprüfung und Speicherung statt dauerhaft bei 10 % zu stehen. +- Alte oder beschädigte Installationsstatusdateien können nicht mehr zu `unknown · Bereitschaft 100%` führen, wenn die reale Installation vollständig vorhanden ist. +- Der konfigurierte Installationspfad `local-ai/comfyui` wird korrekt auf den enthaltenen Portable-Ordner aufgelöst. + +### Technik + +- Neue ComfyUI-Referenzpipeline: UploadImage → LoadImage → ImageScale → VAEEncode → KSampler. +- Neue Datenbankspalte `ai_model_jobs.progress_message` als additive Migration. +- BOM-sicheres Lesen des FLUX-Installationsstatus. +- Neuer PowerShell-5.1-kompatibler Uninstaller `scripts/uninstall-local-flux.ps1`. + +## [1.14.6] - 2026-07-07 + +### Behoben + +- Lokaler FLUX-Workflow wird unter Windows PowerShell 5.1 als UTF-8 ohne BOM geschrieben. +- Bereits vorhandene Workflow-Dateien mit UTF-8-BOM werden beim Einlesen robust bereinigt. +- `Unexpected token '' ... is not valid JSON` bei der lokalen AI-Bildgenerierung behoben. +- Installer-Status-JSON wird ebenfalls ohne BOM geschrieben, damit Node.js und andere JSON-Parser es zuverlässig lesen können. + +# 1.14.5 – Slice 22.3 PowerShell 5.1 Parser Fix + +- Behebt den PowerShell-5.1-Parserfehler `InvalidVariableReferenceWithDrive` im sichtbaren FLUX-Downloadstatus. +- Ersetzt die beiden ungültigen Zeichenfolgen `$Label:` durch die eindeutig begrenzte Form `${Label}:`. +- Der bestehende Resume-Download, die `.part`-Datei und der automatische Setup-Ablauf bleiben unverändert erhalten. +- Keine Datenbank-, Konfigurations-, Upload- oder Listing-Daten werden verändert. + +# 1.14.4 – Slice 22.2 Download Recovery + +- Fortsetzbarer HTTP-Streaming-Download statt BITS/Invoke-WebRequest. +- Sichtbare Fortschrittsanzeige mit Prozent, MB, Geschwindigkeit und Restzeit. +- Automatische Wiederaufnahme über setup.bat. + +# Changelog + +## Slice 22.1 / 1.14.3 – Setup-integrated PowerShell 5.1 recovery +- Fixed the false 7-Zip failure when `Process.ExitCode` is empty under Windows PowerShell 5.1 despite a successful extraction. +- A missing exit code is accepted only when `Everything is Ok` is present, stderr is empty and the extracted result passes file-count and size checks. +- Complete `local-ai\extract-temp` results are reused instead of being deleted and extracted again. +- `setup.bat` now starts with UTF-8 console handling, `-NoProfile` and explicit exit-code reporting. +- Normal `setup.bat` startup detects the exact confirmed Slice-22 false-failure state and offers automatic continuation. +- Automatic continuation installs ComfyUI, downloads the FLUX.1-schnell model and starts the local image server without a separate patch command. +- Installer intent flags are persisted in `install-state.json` for future resumable runs. +- No reset, database change or feature removal is performed. + +## Slice 22 / 1.14.2 – 7-Zip extraction watchdog and real FLUX install telemetry +- Replaced the blocking 7-Zip invocation with a supervised process and explicit path quoting. +- Live extraction status now includes elapsed time, extracted file count, extracted MB, process ID and heartbeat. +- Added 7-Zip stdout/stderr logs and a visible UI panel for extraction details. +- Detects stalled installers instead of showing an endless running state. +- Reuses complete downloads and safely restarts only the failed extraction/install step. +- Handles nested ComfyUI portable archive layouts more robustly. +- Setup status now shows live extraction counters and stale heartbeat detection. + +## Slice 21 – FLUX Installer 7-Zip Hotfix & Detailed Status + +- Fixed PowerShell 5.1 scalar-string bug where a single 7-Zip path such as `C:\Program Files\7-Zip\7z.exe` was indexed as the single character `C`. +- 7-Zip discovery now uses a real string list, refreshes PATH after winget installation and validates the final executable path. +- Partial ComfyUI and FLUX downloads now use `.part` files and are validated before being promoted to complete files. +- BITS downloads now report actual transfer percentage, bytes transferred and total bytes into `local-ai/install-state.json`. +- Installer failures preserve the last real progress value and include source position plus resumable-state metadata. +- Settings UI now shows detailed checks for 7-Zip, archive, Python runtime, ComfyUI, FLUX model, workflow and start/stop scripts. +- Install log tail, error details, readiness score and a resume-aware install button were added. +- Setup option 8 now prints phase, progress, file checks, runtime reachability and recent install log lines. + +## Slice 20 – Vollautomatischer lokaler FLUX-Installer + +- Automatischer Download der aktuellen offiziellen ComfyUI-Windows-Portable-Version über die GitHub-Releases-API +- GPU-Erkennung für NVIDIA, AMD, Intel und CPU-Fallback +- Automatische 7-Zip-Installation über winget, falls erforderlich +- Optionaler Download von FLUX.1-schnell FP8 aus dem offiziellen Comfy-Org-Hugging-Face-Repository +- Fortschritts- und Statusdatei unter `local-ai/install-state.json` +- Fortsetzbare Downloads über BITS mit WebRequest-Fallback +- Sichere Startparameter: Bindung an `127.0.0.1`, kein Browser-Autostart, eigener Port 8188 +- Optionale Windows-Autostart-Aufgabe `Vendoo Local FLUX` +- UI-Aktionen für Installieren, Aktualisieren, Starten, Stoppen und Status prüfen +- Setup-Menü schützt weiterhin vor versehentlicher Neuinstallation von Vendoo und erkennt vorhandene FLUX-Installationen +- Provider-Reihenfolge im Auto-Modus: lokal FLUX → OpenRouter Free → OpenAI + +## Slice 19 – Local FLUX / ComfyUI Bootstrap, safer setup install flow +- Setup hardened: Install checks whether Vendoo is already installed and suggests Start / Update / Repair instead of blindly reinstalling. +- New setup options: local FLUX / ComfyUI bootstrap and status checks. +- New local image provider architecture for AI model photos: Auto now supports Local FLUX -> OpenRouter Free -> OpenAI. +- New server routes for local image status, bootstrap install and token regeneration. +- Settings UI expanded with local FLUX URL, token, workflow path and install path. +- Added local workflow bootstrap template and helper scripts under `scripts/` and `local-ai/workflows/`. + +## Vendoo 1.12.0 – OpenRouter Diagnostics & Mobile Mediathek + +- Mobiler QR-Upload besitzt jetzt getrennte Aktionen für **Kamera** und **Mediathek** +- Mehrfachauswahl aus der Smartphone-Mediathek ohne `capture`-Zwang +- HEIC/HEIF werden beim mobilen Upload nach Möglichkeit automatisch in JPG konvertiert +- OpenRouter-Free-Erkennung bewertet nur bildrelevante Preisfelder +- OpenRouter versucht je Modell mehrere kompatible Chat-Request-Varianten und danach die Images API +- Referenzbilder werden nur an Modelle gesendet, die Bildinput unterstützen +- Klare Diagnose, wenn aktuell **0 kostenlose Bildmodelle** verfügbar sind +- Bei Fehlern werden weiterhin alle passenden Free-Modelle nacheinander versucht; Auto fällt anschließend auf OpenAI zurück + +## Vendoo 1.11.0 – LAN QR Upload, OpenRouter Fallback & Mobile Nav Fix + +- QR-Handy-Upload verwendet automatisch eine erreichbare LAN-IP statt localhost +- Vendoo lauscht für lokale Netzwerkzugriffe auf `0.0.0.0` und zeigt erkannte LAN-Adressen beim Start an +- Neue Einstellung für `PUBLIC_BASE_URL` inklusive automatischer LAN-IP-Erkennung +- OpenRouter probiert bei Fehlern automatisch weitere passende Free-Bildmodelle und danach – im Auto-Modus – OpenAI +- OpenRouter-Bildgenerierung unterstützt Chat-Completions und Images-API als zweistufigen Fallback +- Mobile Navigation vollständig gehärtet: Overlay, Close-Button, Fokus, Escape, Scroll-Lock und responsive Labels + +## Vendoo 1.10.0 – AI Jobs, 1–3 Varianten & History + +- AI-Bildgenerierung läuft jetzt über eine Job-Queue statt nur synchron +- Mindestanzahl auf **1 Bild** gesenkt, Maximum bleibt **3 Bilder** +- Neue Modi: **Model**, **Ghost Mannequin**, **Flatlay** +- Listing-Editor zeigt jetzt eine **AI-Bild-Historie** zum schnellen Übernehmen früherer Generierungen +- Settings erweitert um Standard-Modus für AI-Bilder + +## Vendoo 1.9.0 – AI Provider Settings Center + +- Neuer Settings-Bereich für AI-Model-Fotos mit Provider-Steuerung +- Admin kann jetzt `auto`, `openrouter` oder `openai` für Bildgenerierung wählen +- OpenRouter-Free-Modelle können direkt aus der UI geladen und geprüft werden +- Safety-Flags, Standard-Preset, Variantenanzahl und Fallback-Modell sind in der UI konfigurierbar + +## Vendoo 1.8.0 – OpenRouter Free Image Discovery + +- AI-Model-Fotos unterstützen jetzt einen Provider-Modus `auto` +- OpenRouter Free-Bildmodelle werden dynamisch über die Models-API abgefragt und bei Verfügbarkeit bevorzugt genutzt +- Fallback auf OpenAI (`gpt-image-1` oder konfiguriertes Modell), wenn keine freien OpenRouter-Modelle verfügbar sind +- Neue API-Route für verfügbare OpenRouter-Bildmodelle sowie Persistenz von Provider und Modell je Generierung + +## Vendoo 1.7.0 – AI-Model-Fotos & Safety Layer + +- Neuer sicherer AI-Model-Foto-Workflow für Kleidung ohne Person auf dem Originalfoto +- 2–3 KI-generierte Zusatzbilder mit fiktiven erwachsenen Models in Studio-, Indoor- oder Outdoor-Looks +- Precheck & Output-Safety: blockiert Unterwäsche, Bademode, transparente Erotik-Looks, Kinderkleidung und Quellen mit Personen +- Neue Backend-Routen, Datenbanktabellen und Listing-Integration für Generator und Detailansicht + +# Changelog + +## [Unreleased] + +## [1.6.2] - 2026-07-07 + +### Branding + +- neues Vendoo-Hauptlogo in die App-Sidebar integriert +- neues Branding auf Login und mobilem QR-Fotoupload integriert +- vollständiges Favicon-Paket mit ICO, PNG, Apple Touch Icon und Webmanifest eingebunden +- mobile App-Icon-Größen von 16 bis 512 Pixel ergänzt +- Chrome-, Edge- und Firefox-Erweiterungen auf die neue Vendoo-Icon-Familie aktualisiert +- Vinted Assistant und minimierter Assistant verwenden das neue Logo + +## [1.6.1] - 2026-07-07 + +### Verbessert + +- Dashboard ist jetzt der erste Menüpunkt und die Standard-Startseite. +- Dashboard-KPI-Karten verwenden eine fließende Auto-Fit-Struktur statt starrer Sechserspalten. +- Sparklines besitzen eine reservierte Diagrammfläche und überlagern keine Texte mehr. +- Umsatzdiagramme werden bei Größenänderungen automatisch neu berechnet. +- Dashboard-Charts passen sich an Notebook-, Tablet- und Mobilbreiten an. +- Studio-Flow-Karten verwenden proportionale Bildflächen mit vollständiger `contain`-Darstellung. +- Produktbilder werden nicht mehr abgeschnitten und erhalten einen sauberen Fehlerzustand. +- Ready-Karten verteilen sich automatisch auf die verfügbare Breite. + +## [1.6.0] - 2026-07-07 + +### Hinzugefügt + +- neuer Sidebar-Menüpunkt **Dashboard** mit interaktiven Kennzahlen, Umsatzverlauf, Plattform- und Kategorieauswertung +- Dashboard-Bereiche für Fokusaufgaben, Top Seller, Aktivität, Benachrichtigungen, AI-Qualität und Systemstatus +- Fotoarchiv-Endpunkt: mehrere Fotos werden serverseitig als gültiges ZIP erzeugt +- Foto-Download im Generator und vollständigen Listing-Editor + +### Geändert + +- Studio Flow vollständig neu strukturiert: Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- die fachlich unpassenden sichtbaren Stufen „Neu erfasst“ und „AI-Prüfung“ wurden entfernt +- Studio Flow zeigt nur priorisierte Artikel statt einer überfüllten Spaltenwand +- geplante Listings, Probleme und Aktivitäten sind in klar getrennten Arbeitsbereichen organisiert +- Dashboard und Studio Flow für Notebook, iPad, Tablet und Smartphone neu priorisiert + +### Behoben + +- Publish-Center lädt Listings beim ersten Öffnen mit Wiederholungslogik und verhindert veraltete parallele Renderings +- Publish-Ladefehler zeigt jetzt eine konkrete Wiederholen-Aktion statt einer Sackgasse +- bei einem Foto erfolgt ein normaler Download, bei mehreren Fotos automatisch ein ZIP-Download + +## [1.5.0] - 2026-07-07 + +### Hinzugefügt + +- Browser-Erweiterungen in getrennten Ordnern für Chrome, Edge und Firefox +- vollständiger Design-Relaunch von Popup und Vinted Assistant +- neues Vendoo-Link-Icon in 16, 32, 48 und 128 Pixel +- QR-basierter Handy-Fotoupload für Generator und Listing-Editor +- sichere, zeitlich begrenzte Mobile-Upload-Sitzungen +- responsive Layout-Gates für Desktop, Notebook, iPad und Smartphone +- umschaltbare Plattformvorschau und HTML-Codeansicht + +### Verbessert + +- Produktbilder der Extension werden vollständig dargestellt +- CSRF-Unterstützung für schreibende Extension-Requests +- öffentliche Serveradresse statt festem localhost für Extension-Bilder +- Plattformvorschau und HTML-Code sind in Generator und Listing-Editor als Umschalter zusammengeführt +- Layouts für Desktop, Notebook, iPad und Smartphone neu priorisiert +- freigegebene Extension- und Icon-Mockups als verbindliche Designreferenz archiviert + +### Behoben + +- Studio Flow: „Prüfen & bearbeiten“ wechselt jetzt zuverlässig zur sichtbaren Listing-Detailansicht. +- Detailaufrufe aus Attention Queue und anderen Bereichen verwenden denselben sicheren Navigationspfad. +- Ungültige Listing-IDs werden abgefangen und als Hinweis angezeigt. + +- Windows-Installer behandelt npm `ENOTEMPTY`/`EPERM` robust, bereinigt unvollstaendige `node_modules`, protokolliert npm-Ausgaben und wiederholt die Installation einmal. +- Installation nutzt mit vorhandenem Lockfile reproduzierbar `npm ci` statt eines stillen `npm install`. + +### Geplant + +- Lager, Vorlagen, Einstellungen und Admin gemäß Design-Freeze +- Docker- und Online-Readiness + +## [1.4.0-full-listing-editor] - 2026-07-07 + +### Hinzugefügt + +- Listings lassen sich jetzt vollständig in einem eigenen Arbeitsbereich bearbeiten +- Fotoverwaltung mit Hinzufügen, Entfernen, Cover-Auswahl, Drag-to-Sort und vollständigem Bildeditor ergänzt +- WYSIWYG-Editor und HTML-Code sind im Listing-Editor bidirektional und live synchronisiert +- Generator zeigt HTML-Code und Plattformvorschau gleichzeitig; Änderungen werden ohne Umschalten übernommen +- Plattform-HTML wird während der Bearbeitung live neu gerendert +- neue Datenbankspalte `description_html` mit Migration und Rückwärtskompatibilität +- neuer API-Endpunkt `POST /api/html-render` für nicht persistierende Live-Vorschauen +- Editor-HTML und vollständiges Plattform-HTML können getrennt kopiert werden + +### Sicherheit + +- Rich-Text-HTML wird client- und serverseitig auf eine begrenzte Tag- und Attributmenge reduziert +- Plattformvorschauen laufen in sandboxed iframes +- Listing-Updates verwenden jetzt eine feste Feld-Allowlist statt beliebiger SQL-Spaltennamen + +### Behoben + +- `photos` werden bei Listing-Updates korrekt als JSON gespeichert +- Bildbearbeitung funktioniert nun auch für bereits gespeicherte Listings +- formatierte Beschreibungen bleiben nach Speichern und erneutem Öffnen erhalten + +## [1.3.0-publish-center] - 2026-07-07 + +### Geändert + +- Publish-Bereich als durchgängiges operatives Publish-Center neu aufgebaut +- Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung in einer gemeinsamen Oberfläche zusammengeführt +- bildorientierte Listing-Auswahl mit Suche, Plattform-/Statusfilter, Sortierung und Mehrfachauswahl ergänzt +- internes Workspace-Modell statt separater Queue- und Smart-Copy-Modale eingeführt +- direkte Listing-Bearbeitung vor Etsy-/eBay-Veröffentlichung integriert +- Publish-Bereitschaft, Pflichtfelder, Integrationsstatus und Fotoprüfung sichtbar gemacht +- Queue-Status, Schnellaktionen und Publish-Aktivität in einer festen Statusleiste gebündelt +- eBay-Fehler, Wiederholungen und geplante Veröffentlichungen inline bedienbar gemacht +- responsive Darstellung und Dark Mode für das komplette Publish-Center ergänzt + +### Behoben + +- Batch-Smart-Copy öffnete denselben Vorgang teilweise doppelt +- nicht passende Etsy-Batch-Auswahl wird jetzt gefiltert statt blind veröffentlicht +- Produktbilder im Publish-Center werden vollständig und ohne Beschnitt dargestellt +- Queue- und Planungsaktionen bleiben im Vendoo-Kontext und verlieren nicht den aktuellen Arbeitsstand + +## [1.2.0-listing-studio] - 2026-07-07 + +### Geändert + +- Listing-Generator als dreigeteilter Studio-Arbeitsplatz neu aufgebaut +- Foto-Cover, Sortierung, Qualitätscheck und vollständige Bildanpassung integriert +- AI-Konfiguration mit Plattform, Provider, Modell, Sprache, Varianten und Vorlagen neu strukturiert +- artikelspezifische Verkäufernotizen mit Zeichenzähler ergänzt +- Ergebnisbereich mit Live-Zählern, Qualitätswert, Attributen, Gebühren und HTML-Vorschau modernisiert +- responsive Darstellung und Dark Mode für den Generator vollständig ergänzt + +### API + +- `POST /api/generate` akzeptiert `template_id` und `seller_notes` +- globale Hinweise, Vorlagenhinweise und Artikelhinweise werden dedupliziert kombiniert +- Standard-Tags aus Vorlagen werden mit AI-Tags zusammengeführt + +### Behoben + +- Upload begrenzt neue Bilder clientseitig auf die verbleibenden Plätze bis maximal zehn +- Upload-Fehler werden mit HTTP-Status und Servermeldung korrekt angezeigt +- Dateinamen in Vorschaubildern werden URL-sicher ausgegeben + +## [1.0.1-ui-foundation.1] - 2026-07-07 + +### Hinzugefügt + +- zentrale Vendoo-Design-Tokens für Light und Dark Mode +- neue kompakte App-Shell und Hauptnavigation +- Command-Search-Einstieg mit `Ctrl/⌘ + K` +- neue Startseite `Studio Flow` +- Flow-Stufen: Neu erfasst, AI-Prüfung, Bereit, Geplant, Veröffentlicht, Verkauft +- Attention Queue und Activity Stream +- kontextueller Artikel-Inspector +- neuer Endpunkt `GET /api/dashboard/workflow` +- Dokumentation `docs/UI_FOUNDATION_2026.md` +- sicherer `.gitignore` + +### Sicherheit + +- `.env`, SQLite-Laufzeitdateien, Uploads und `node_modules` aus dem Übergabepaket entfernt +- keine produktiven Zugangsdaten in der bereinigten Version + +## [1.1.1-ui-polish] - 2026-07-07 + +### Geändert + +- Etiketten und Artikeldatenblätter öffnen jetzt im internen Vendoo Print Studio statt in einem neuen Browserfenster +- maßstabsgetreue A4-Vorschau mit zwei Etikettenformaten, Exemplaren und Preisoption +- Druck erfolgt im aktuellen Vendoo-Fenster über den normalen Systemdruckdialog +- Studio-Flow-Produktbilder verwenden vollständige automatische Anpassung (`object-fit: contain`) +- Inspector- und Attention-Bilder werden ebenfalls ohne Beschnitt dargestellt +- Vinted-Extension auf die freigegebene Vendoo-Designsprache 2026 umgestellt +- Extension-Popup und eingeblendetes Vinted-Panel modernisiert +- Produktbilder der Extension werden sicher über den Background-Service-Worker als Data-URL geladen +- Pfade von Upload-Bildern werden korrekt URL-codiert und Bildfehler erhalten einen sichtbaren Zustand +- fehlerhaft doppelt codierte deutsche Texte in der Extension repariert + +### Behoben + +- Produktvorschaubilder in der Vinted-Extension wurden durch Seiten-CSP/Mixed-Content-Konstellationen teilweise nicht angezeigt +- große oder hochformatige Produktbilder wurden im Studio Flow abgeschnitten + +## [1.1.0-ui-listings] - 2026-07-07 + +### Geändert + +- Listings-/Historie-Seite vollständig an die freigegebene Vendoo-Designrichtung 2026 angepasst +- neue Filterleiste mit Suche, Plattform, Status und Zeitraum +- benutzerdefinierbare sichtbare Tabellenspalten mit lokaler Speicherung +- dichte, bildorientierte Listing-Tabelle mit SKU, Plattform, AI-Provider, Preis, Status, Lagerort und Publish-Status +- neue Batch-Aktionsleiste für Publish, Nachbearbeitung, Preis, Status, Duplizieren, Etiketten und Löschen +- moderne Seitennavigation und Seitengrößensteuerung +- neue Exportauswahl für CSV und JSON +- verbesserter leerer Zustand und responsive Darstellung +- bestehende CRUD-, Export-, Druck-, Status- und Papierkorb-Funktionen erhalten + +## [1.6.1] - 2026-07-07 + +### Geändert + +- Vendoo Assistant für Chrome, Edge und Firefox vollständig verdichtet und visuell neu strukturiert. +- Queue-Karten zeigen Bild, Status, SKU, Preis und direkte Aktionen ohne große Leerflächen. +- „Nur ausfüllen“ ist jetzt die primäre und sichere Einzelaktion. +- Daten können in Vinted eingetragen werden, ohne automatisch einen Entwurf anzulegen oder zu veröffentlichen. +- „Daten + Bilder ausfüllen“ überträgt Bilder und Felder, führt aber keinen Abschluss aus. +- Entwurf und Veröffentlichung liegen bewusst in einem getrennten, aufklappbaren Abschlussbereich. +- Popup öffnet Vinted mit einem vorgemerkten Listing und füllt es nach dem Laden aus. +- Extension-Version auf 1.4.0 angehoben. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CLAUDE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CLAUDE.md new file mode 100644 index 0000000..084fcb4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/CLAUDE.md @@ -0,0 +1,96 @@ +# Vendoo — Projekt-Kontext + +Du arbeitest am Projekt "Vendoo" — ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +Lies zuerst `FEATURES.md` im Projektroot — das ist die vollstaendige Feature-Dokumentation mit Tech Stack, Projektstruktur, DB-Schema, allen 80+ API-Endpunkten und 17 Feature-Beschreibungen. + +## Tech Stack + +- **Backend:** Node.js + Express (ESM, `"type": "module"`) +- **Frontend:** Vanilla HTML/CSS/JS — kein Framework, kein Build-Step, kein TypeScript +- **Datenbank:** SQLite via `better-sqlite3` (WAL-Modus) +- **AI:** Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) +- **Bilder:** `sharp` (Resize, BG-Remove, Watermark) +- **Rich-Text:** Quill Editor +- **E-Mail:** Nodemailer (SMTP) +- **Extension:** Chrome Manifest v3 + +## Dateistruktur (wo was hingehoert) + +| Aenderung | Datei(en) | +|---|---| +| Neue API-Route | `server.mjs` — Route hinzufuegen + Import erweitern | +| Neue DB-Funktion | `lib/db.mjs` — Funktion exportieren | +| Neue DB-Spalte | `lib/db.mjs` — MIGRATIONS-Array erweitern (try/catch ALTER TABLE Pattern) | +| Neues UI-Tab | `public/index.html` (Sidebar-Button + Section), `public/app.js` (TAB_TITLES + switchToTab + Logik), `public/style.css` | +| Neuer AI-Provider | `lib/ai-*.mjs` + `lib/ai-router.mjs` erweitern | +| Neue Plattform | `platforms/*.mjs` + `lib/categories.mjs` + `lib/fees.mjs` + `lib/html-templates.mjs` | +| Styling | `public/style.css` — CSS Custom Properties fuer Theming | + +## Konventionen + +- **Sprache:** UI ist komplett Deutsch +- **SKU-Format:** `VD-XXXX` (auto-generiert, 4-stellig aufsteigend) +- **Navigation:** Sidebar mit `data-tab` Attributen, Tab-Inhalte als `
` +- **Topbar-Titel:** `TAB_TITLES` Map in `app.js` muss fuer neue Tabs erweitert werden +- **Dark Mode:** CSS-Variablen unter `[data-theme="dark"]`, niemals hardcoded Farben +- **Auth:** Session-basiert, Rollen `admin`/`editor`, CSRF-Token bei state-changing Requests +- **DB-Migrationen:** Neue Spalten als Eintrag in `MIGRATIONS`-Array (Zeile ~17-21 in db.mjs): + ```js + const MIGRATIONS = [ + ['spaltenname', 'TEXT'], // oder 'REAL', "TEXT DEFAULT 'wert'" + ]; + // Wird automatisch per try/catch ALTER TABLE ausgefuehrt + ``` +- **API-Pattern:** REST, JSON-Body, Fehler als `{ error: 'message' }` +- **Frontend-API-Aufrufe:** `api(method, path, body)` Helper in app.js (fuegt Auth + CSRF automatisch hinzu) +- **Toast-Benachrichtigungen:** `toast('Nachricht')` in app.js +- **Quill-Editor:** `initQuill(selector, placeholder)` — gibt Editor-Instanz zurueck + +## Server starten + +```bash +npm install # Dependencies installieren +npm run dev # Dev-Server mit --watch (Port 8124) +npm start # Produktiv-Server +``` + +Oder: `setup.bat` fuer gefuehrte Installation. + +## Wichtige Dateien nach Groesse + +| Datei | Zeilen | Inhalt | +|---|---|---| +| `public/app.js` | ~3240 | Gesamte Frontend-Logik | +| `server.mjs` | ~1250 | Alle API-Routen + Middleware | +| `public/style.css` | ~1200 | Komplettes Styling + Dark Mode + Responsive | +| `public/index.html` | ~845 | HTML-Struktur (Sidebar, Tabs, Modals, Forms) | +| `lib/db.mjs` | ~390 | Datenbank-Zugriff (alle CRUD-Funktionen) | +| `lib/categories.mjs` | ~360 | Kategorie-Baeume pro Plattform | +| `lib/auth.mjs` | ~290 | Auth-System (Users, Sessions, Audit) | +| `lib/ebay-api.mjs` | ~270 | eBay REST API + OAuth2 | +| `lib/mailer.mjs` | ~210 | SMTP E-Mail-Versand | +| `lib/etsy-api.mjs` | ~185 | Etsy REST API v3 + OAuth2 PKCE | +| `extension/content.js` | ~1170 | Chrome Extension Content Script | + +## Aktueller UI-Stand + +- Version `1.3.0` +- UI 2026 Slice 05: Publish-Center +- Smart Copy, direkte Veröffentlichung, eBay Queue und Scheduling arbeiten in einer gemeinsamen Oberfläche. +- Interne Publish-Arbeitsschritte dürfen keine separaten Browserfenster oder redundanten Modale öffnen. + +## Bildarchitektur ab 1.23.0 +- FLUX/ComfyUI ist der einzige lokale generative Bilddienst. +- Das FLUX Studio arbeitet textbasiert und getrennt von Produktbild-Werkzeugen. +- Keine VTO-, Python-, Torch- oder ONNX-Laufzeit wieder einführen, solange kein neuer ausdrücklicher Architekturentscheid dokumentiert ist. + +- FLUX-Studio-Aufträge und Varianten sind in `flux_prompt_jobs` und `flux_prompt_job_variants` persistent. +- Fortschritt im FLUX Studio wird als reale Phase dargestellt; keine aus Laufzeit geschätzten Prozentwerte ergänzen. +- ComfyUI-Prompt-IDs müssen für Abbruch, Historie und Neustart-Wiederaufnahme erhalten bleiben. + +- Der Advanced Image Editor arbeitet nicht destruktiv: Originaldateien niemals überschreiben oder löschen. +- Jede gespeicherte Bearbeitung muss eine neue Datei und einen Eintrag in `image_edit_versions` erzeugen. +- Bildpfade müssen strikt auf `uploads/` begrenzt und gegen Traversal geschützt bleiben. +- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor nicht als Platzhalter ergänzen; diese Funktionen benötigen eigene spätere Slices. + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/Dockerfile b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/Dockerfile new file mode 100644 index 0000000..7687f47 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-bookworm-slim AS dependencies +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json .npmrc ./ +RUN npm ci --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production \ + PORT=8124 \ + VENDOO_BIND_HOST=0.0.0.0 \ + VENDOO_DATA_DIR=/app/data \ + VENDOO_DB_PATH=/app/data/db/vendoo.db \ + VENDOO_UPLOADS_DIR=/app/data/uploads \ + VENDOO_BACKUP_DIR=/app/data/backups \ + VENDOO_OPERATIONS_DIR=/app/data/operations \ + VENDOO_LOG_DIR=/app/data/logs \ + VENDOO_CONFIG_PATH=/app/data/config/vendoo.env +WORKDIR /app +COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules +COPY --chown=node:node . . +RUN mkdir -p /app/data/db /app/data/uploads /app/data/backups /app/data/operations /app/data/logs /app/data/config \ + && chown -R node:node /app/data \ + && chmod +x /app/scripts/docker-entrypoint.sh +USER node +EXPOSE 8124 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] +CMD ["node", "bootstrap.mjs"] diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FEATURES.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FEATURES.md new file mode 100644 index 0000000..50b6649 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FEATURES.md @@ -0,0 +1,1007 @@ +## Slice 32 – Batch Image Factory (1.27.0) + + +## Version 1.36.0 – Secure Theme Manager + +### Persönliche Darstellung + +Jeder angemeldete Benutzer kann Theme, UI-Dichte und reduzierte Bewegung über eine eigene Eingabemaske verwalten. Die Präferenz wird serverseitig dem Benutzer zugeordnet und beim nächsten Start erneut geladen. + +### Administrative Theme-Profile + +Administratoren können validierte Theme-Profile erstellen, bearbeiten, exportieren, importieren und als Systemstandard festlegen. Profile bestehen ausschließlich aus freigegebenen Design Tokens. Freies CSS oder JavaScript wird nicht gespeichert oder ausgeführt. + +### Accessibility + +Der Theme Manager zeigt Kontrastwerte zentraler Farbkombinationen, bietet ein Hochkontrast-Theme, sichtbare Fokuszustände, reduzierte Bewegung und Mindestzielgrößen für Bedienelemente. + +- zentrale Batch-Bildproduktion als eigener Navigationsbereich +- Quellbilder aus FLUX, AI, Artikeln, Editorversionen und früheren Produktionsausgaben +- integrierte Profile für eBay, Vinted, Kleinanzeigen, Etsy, Instagram, Webshop und Originaloptimierung +- eigene Profile mit Größe, Fit-Modus, Hintergrund, Format, Qualität und Dateinamensschema +- mehrere Profile und mehrere Bilder pro Auftrag +- optionale Hintergrundentfernung und Wasserzeichen +- persistente Jobtabellen mit Einzelpositionen, Pause, Fortsetzen, Abbruch, Retry und Neustartwiederaufnahme +- nicht destruktive Versionen und automatische Aufnahme in die zentrale Bildergalerie +- ZIP-Export aller fertigen Ausgaben eines Auftrags + +## Stabilitäts- und Diagnosezentrum (1.26.1) + +Vendoo überwacht jetzt seine lokale Serververbindung, versieht API-Anfragen mit Diagnose-IDs und zeigt einen klaren Verbindungsstatus statt stiller Folgefehler. Administratoren können unter **Admin → System** eine vollständige, geheimnisfreie Systemprüfung starten und das Ergebnis zusammen mit den letzten Clientfehlern kopieren. Die Oberfläche reagiert zusätzlich auf die reale Breite des Arbeitsbereichs neben der Sidebar und nicht nur auf die Browserbreite. + +## Slice 18 – Mobile Mediathek & OpenRouter Diagnostics + +- Getrennte Kamera- und Mediathek-Auswahl im QR-Upload +- Mobile Mehrfachauswahl sowie HEIC/HEIF-Konvertierung +- Robuster OpenRouter-Request-Fallback pro Modell +- Dynamische Free-Modell-Diagnose und klarer OpenAI-Fallback-Hinweis + +## Slice 17 – LAN QR, OpenRouter Failover & Mobile Navigation + +- Automatische private LAN-IP-Auswahl mit Adapter-Priorisierung +- Konfigurierbare `PUBLIC_BASE_URL` für QR-Upload +- Mehrstufiger OpenRouter-Failover über aktuelle Free-Bildmodelle +- Chat-Completions- und Images-API-Fallback für OpenRouter +- Robuste mobile Off-Canvas-Navigation + +## Slice 16 – AI Job Queue & Multi-Mode Image Generation + +- Queue-basierte AI-Bildgenerierung mit Status-Polling +- 1–3 Varianten statt nur 2–3 +- Modi: Model / Ghost Mannequin / Flatlay +- AI-Bildhistorie im Listing-Editor + +## Slice 15 – AI Provider Settings Center + +- UI für AI-Model-Foto-Provider und Free-Model-Discovery +- Live-Abfrage aktueller OpenRouter-Bildmodelle aus der Settings-Seite +- Konfigurierbare Safety-Flags und Fallback-Logik ohne Codeänderung + +## Slice 14 – OpenRouter Free Image Discovery + +- Dynamic Lookup gegen OpenRouter Models API (`output_modalities=image`) +- Filter auf kostenlose Bildmodelle, die sich laufend ändern können +- Provider-Automatik: OpenRouter Free zuerst, dann OpenAI-Fallback +- Speicherung von `provider` und `provider_model` für spätere Nachvollziehbarkeit + +## Slice 13 – AI-Model-Fotos mit Safety-Filter + +- Sichert den Workflow für Modeartikel ohne Person im Originalbild ab +- Erzeugt 1–3 zusätzliche Bilder als Model, Ghost Mannequin oder Flatlay +- Blockiert Unterwäsche, Bademode, transparente Erotik-Looks und Kinderkleidung +- Speichert Generierungen und erzeugte Bilder in separaten Tabellen für spätere Nachverfolgung + +# Vendoo - Feature-Dokumentation + +> Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. +> Stand: 2026-07-08 | Version 1.27.0 + +## Design-Freeze 2026 + +Die freigegebenen Referenzen in `MockupDesign/` sind die verbindliche UI-/UX-Richtung. Die Startseite wird als **Studio Flow** umgesetzt und bildet den Artikelzyklus `Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` ab. Gemeinsame Design-Tokens, App-Shell, Navigation, Dark Mode und Kontextpanels werden zentral gepflegt. + +--- + +## Tech Stack + +| Komponente | Technologie | +|---|---| +| Backend | Node.js + Express (ESM) | +| Frontend | Vanilla HTML/CSS/JS (kein Framework, kein Build) | +| Datenbank | SQLite via `better-sqlite3` (WAL-Modus) | +| AI | Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) | +| Bildverarbeitung | `sharp` (Resize, Hintergrund, Wasserzeichen) | +| Rich-Text | Quill Editor | +| QR-Codes | QRious | +| E-Mail | Nodemailer (SMTP) | +| Auth | Scrypt-Hashing, Session-basiert, Magic Links | +| Extension | Chrome/Edge Manifest v3, Firefox WebExtension, Safari Web Extension | + +--- + +## Projektstruktur + +``` +Vendoo/ +├── server.mjs # Express-Server (1253 Zeilen, alle API-Routen) +├── package.json # Dependencies + Scripts (start, dev) +├── setup.bat / setup.ps1 # Windows-Installer (Install/Update/Repair/Uninstall) +├── .env / .env.example # Konfiguration (API-Keys, SMTP, Port) +├── db/ +│ ├── schema.sql # DB-Schema (listings, templates, settings, publish_log) +│ └── vendoo.db # SQLite-Datenbank +├── lib/ +│ ├── db.mjs # Datenbank-Zugriff (alle CRUD-Funktionen, ~390 Zeilen) +│ ├── auth.mjs # Auth-System (Users, Sessions, Rate-Limiting, Audit, ~290 Zeilen) +│ ├── ai-router.mjs # AI-Provider-Dispatcher +│ ├── ai-claude.mjs # Anthropic Claude Vision API +│ ├── ai-openai.mjs # OpenAI GPT-4o Vision API +│ ├── ai-openrouter.mjs # OpenRouter Multi-Modell API (~110 Zeilen) +│ ├── ai-local.mjs # Ollama lokale Inferenz +│ ├── images.mjs # Bildverarbeitung (Resize, BG-Remove, Watermark) +│ ├── fees.mjs # Gebührenberechnung pro Plattform +│ ├── categories.mjs # Kategorie-Bäume pro Plattform (~360 Zeilen) +│ ├── html-templates.mjs # Plattform-spezifische HTML-Beschreibungs-Wrapper +│ ├── mailer.mjs # SMTP E-Mail-Versand + Templates (~210 Zeilen) +│ ├── etsy-api.mjs # Etsy REST API v3 + OAuth2 PKCE (~185 Zeilen) +│ └── ebay-api.mjs # eBay REST API + OAuth2 (~270 Zeilen) +├── platforms/ +│ ├── vinted.mjs # Vinted Prompt-Template + Parsing +│ ├── ebay-ka.mjs # eBay Kleinanzeigen +│ ├── ebay-de.mjs # eBay.de +│ └── etsy.mjs # Etsy +├── public/ +│ ├── index.html # Haupt-UI (Single Page, ~845 Zeilen) +│ ├── login.html # Login-Seite (Setup, Magic Link, Passwort) +│ ├── style.css # Komplettes Styling inkl. Dark Mode (~1200 Zeilen) +│ └── app.js # Frontend-Logik (~3240 Zeilen) +├── extension/ +│ ├── manifest.json # Chrome Extension Manifest v3 +│ ├── background.js # Service Worker +│ ├── popup.html/js # Extension-Popup +│ ├── content.js # Content Script für Vinted (~1170 Zeilen) +│ ├── content.css # Injiziertes Styling +│ └── vinted-categories.js # Vinted-Kategorie-Mapping +└── uploads/ # Hochgeladene Produktfotos +``` + +--- + +## Datenbank-Schema + +### Tabelle: `listings` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| platform | TEXT NOT NULL | vinted, ebay-ka, ebay-de, etsy | +| ai_provider | TEXT NOT NULL | claude, openai, openrouter, local | +| title | TEXT | Listing-Titel | +| description | TEXT | Beschreibungstext | +| tags | TEXT (JSON) | Array von Tags/Hashtags | +| photos | TEXT (JSON) | Array von Dateinamen | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| brand | TEXT | Marke | +| category | TEXT | Produktkategorie | +| size | TEXT | Groesse | +| color | TEXT | Farbe | +| condition | TEXT | Zustand | +| price | REAL | Preis in EUR | +| status | TEXT DEFAULT 'active' | active, sold, reserved | +| language | TEXT DEFAULT 'de' | Sprache (de,en,fr,nl,pl,es,it,pt) | +| sku | TEXT | Artikelnummer (VD-0001 Format) | +| storage_location | TEXT | Lagerort (z.B. "Regal A2") | +| deleted_at | TEXT | Soft-Delete Timestamp | +| created_at | TEXT | Erstellungsdatum | +| updated_at | TEXT | Letzte Aenderung | + +### Tabelle: `templates` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| name | TEXT NOT NULL | Vorlagen-Name | +| platform | TEXT | Plattform (null = alle) | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| language | TEXT DEFAULT 'de' | Sprache | +| default_tags | TEXT (JSON) | Standard-Tags | +| created_at | TEXT | Erstellungsdatum | + +### Tabelle: `publish_log` +Tracking aller Veroeffentlichungen (listing_id, platform, status, external_id, external_url, published_at) + +### Tabelle: `ebay_queue` +eBay-Publish-Warteschlange (listing_id, status, ebay_item_id, ebay_url, error, retries) + +### Tabelle: `scheduled_publish` +Zeitgesteuerte Veroeffentlichungen (listing_id, platform, scheduled_at, status, error) + +### Tabelle: `settings` +Key-Value Store (default_platform, default_ai, seller_notes, API-Keys, SMTP-Config) + +### Tabelle: `users` +Benutzerverwaltung (email, name, password_hash, role, active, avatar_color, invited_by) + +### Tabelle: `sessions` +Auth-Sessions (user_id, token_hash, ip, user_agent, expires_at) + +### Tabelle: `auth_tokens` +Magic Links + Einladungen (email, token_hash, type, expires_at, used) + +### Tabelle: `rate_limits`, `audit_log`, `login_history` +Sicherheit und Nachverfolgung + +--- + +## Features im Detail + +### 1. AI-Listing-Generator (Kernfeature) + +**Was:** Fotos hochladen → AI generiert komplettes Listing (Titel, Beschreibung, Tags, Metadaten) + +**Ablauf:** +1. Fotos per Drag & Drop oder Dateiauswahl hochladen (max. 10, je 20MB) +2. Plattform waehlen (Vinted, eBay KA, eBay.de, Etsy) +3. AI-Provider waehlen (Claude, OpenAI, OpenRouter, Ollama) +4. Modell waehlen (bei OpenRouter/Ollama) +5. Sprache waehlen (8 Sprachen) +6. Varianten waehlen (1-3) +7. Optional: Vorlage waehlen (Seller Notes) +8. „Artikel generieren“ klicken +9. AI analysiert Fotos und liefert: Titel, Beschreibung, Tags, Marke, Kategorie, Groesse, Farbe, Zustand, Preisvorschlag +10. Ergebnis in editierbaren Feldern anzeigen (Quill WYSIWYG-Editor) +11. HTML-Vorlage mit plattform-spezifischem Styling generieren +12. Gebuehrenberechnung automatisch anzeigen +13. Speichern erzeugt Listing mit auto-generierter SKU (VD-XXXX) + +**Dateien:** `server.mjs` (POST /api/generate), `lib/ai-*.mjs`, `platforms/*.mjs`, `public/app.js` + +**API:** `POST /api/generate`, `POST /api/upload`, `GET /api/providers`, `GET /api/platforms` + +--- + +### 2. Foto-Management & Editor + +**Upload:** +- Drag & Drop Dropzone +- Multi-File-Auswahl (nur Bilder: jpg, png, webp, gif) +- Vorschau-Thumbnails mit Entfernen-Button +- Drag-to-Sort (Reihenfolge aendern) + +**Bildbearbeitung (Canvas-basiert):** +- Helligkeit (-100 bis +100) +- Kontrast (-100 bis +100) +- Saettigung (-100 bis +100) +- Drehen (links/rechts 90°) +- Spiegeln (horizontal) +- Zuschneiden (Crop-Tool) +- Zuruecksetzen auf Original +- Uebernehmen/Abbrechen + +**Bildtools:** +- Hintergrund entfernen (Corner-Sample-Methode, Toleranz 60) +- Wasserzeichen hinzufuegen (Text-Overlay, 4% Schriftgroesse, 40% Opazitaet) + +**API:** `POST /api/upload`, `POST /api/images/remove-bg`, `POST /api/images/watermark` + +--- + +### 3. Listing-Verwaltung (Historie) + +**Such- und Filteroptionen:** +- Volltextsuche (Titel, Beschreibung, Marke) mit 300ms Debounce +- Plattform-Filter (Dropdown) +- Status-Filter (Aktiv, Verkauft, Reserviert) +- Datumsbereich-Filter (Von/Bis) + +**Listenansicht:** +- Thumbnail + Titel + SKU-Badge +- Plattform + AI-Provider + Preis + Datum +- Lagerort-Anzeige (wenn zugewiesen) +- Publish-Status-Badges pro Plattform (published/copied/pending) +- Inline-Status-Aenderung (Dropdown) +- Duplizieren-Button +- Loeschen-Button (Soft-Delete → Papierkorb) + +**Batch-Operationen:** +- Alle auswaehlen / Einzelauswahl +- Ausgewaehlte loeschen +- Alle loeschen (gefiltert) +- Status massenweise aendern (Aktiv/Verkauft/Reserviert) +- Etiketten drucken (ausgewaehlte) + +**Pagination:** +- Seitengroesse: 10 / 20 / 50 / Alle +- Vor/Zurueck (oben + unten) +- Seite X von Y, Gesamtanzahl + +**Export:** +- CSV-Export (mit Plattform/Status-Filter) +- JSON-Export (alle gefilterten Listings) + +**Detail-Ansicht:** +- vollständiger Listing-Arbeitsbereich statt reduziertem Detailformular +- Fotogalerie mit großem Cover, Thumbnail-Leiste und vollständiger Bilddarstellung +- Fotos hinzufügen, entfernen, per Drag & Drop sortieren und als Cover festlegen +- vorhandene Bilder direkt mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- QR-Code (SKU + ID, via QRious) +- Alle Felder editierbar: Plattform, AI-Provider, Sprache, SKU, Status, Titel, Tags, Verkäufernotizen, Marke, Kategorie, Groesse, Farbe, Zustand, Preis, Lagerort +- Beschreibung gleichzeitig im Quill-WYSIWYG und im HTML-Code bearbeiten +- WYSIWYG- und HTML-Codeansicht sind live und bidirektional synchronisiert +- separate sandboxed Plattformvorschau +- Editor-HTML und vollständiges Plattform-HTML separat kopierbar +- Aenderungen speichern +- Alles kopieren +- Etikett erstellen +- Datenblatt erstellen +- Loeschen + +**API:** `GET/POST/PUT/DELETE /api/listings`, `POST /api/listings/:id/duplicate`, `GET /api/export/csv` + +--- + +### 4. Vorlagen-System (Templates) + +**3 vordefinierte Vorlagen (Seed):** +1. "Sneaker & Schuhe" (Vinted, DE) — Versand-/Zustandshinweise + Sneaker-Tags +2. "Vintage Kleidung" (Vinted, DE) — Vintage-spezifische Hinweise + Retro-Tags +3. "Elektronik & Zubehoer" (eBay.de, DE) — Funktionshinweise + Technik-Tags + +**Verwaltung:** +- Liste aller Vorlagen (Name, Plattform, Sprache) +- Erstellen: Name, Plattform (optional), Sprache, Seller Notes (Quill), Standard-Tags +- Bearbeiten / Loeschen +- Vorlagen-Auswahl im Generator (Dropdown) +- Bei Auswahl: uebernimmt Plattform + Sprache + Seller Notes + Tags + +**API:** `GET/POST/PUT/DELETE /api/templates` + +--- + +### 5. Publishing-System + +**UI 2026 / Slice 05 – Publish-Center:** +- gemeinsame Arbeitsoberfläche für Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung +- bildorientierte Listing-Auswahl mit Suche, Filtern, Sortierung und Mehrfachauswahl +- inline bearbeitbare Publish-Daten mit Pflichtfeld- und Integrationsprüfung +- Queue-Status, Fehlerwiederholung, Aktivitätsverlauf und Schnellaktionen in einer Statusleiste +- keine separaten Browserfenster für interne Publish-Arbeitsschritte + +**Smart Copy (manuelles Veroeffentlichen):** +- Schritt-fuer-Schritt-Anleitung pro Plattform +- Fotogalerie mit Download +- Kopierbare Felder: Titel, Beschreibung (Text + HTML), Tags, Preis, Metadaten +- "Plattform oeffnen" Button (Direkt-URL) +- "Als veroeffentlicht markieren" Button + +**Direkte API-Veroeffentlichung:** +- Etsy: OAuth2 + PKCE → Listing erstellen + Bilder hochladen +- eBay.de: OAuth2 → Inventory Item + Offer + Publish + +**eBay-Warteschlange:** +- Listings zur Queue hinzufuegen (Batch) +- Queue-Ansicht: SKU, Titel, Status, Fehler +- Alle verarbeiten / Fehlgeschlagene wiederholen / Queue leeren +- Status-Tracking: pending → uploading → published/failed + +**Zeitgesteuerte Veroeffentlichung:** +- Datum + Uhrzeit waehlen +- Plattform waehlen +- Batch-Scheduling moeglich +- Uebersicht aller geplanten Veroeffentlichungen +- Background-Job prüft alle 60 Sekunden auf faellige Items + +**Publish-Status-Tracking:** +- Publish-Log pro Listing + Plattform +- Status-Badges in Historie + Publish-Ansicht + +**API:** `POST /api/publish/smart-copy`, `POST /api/publish/mark`, `POST /api/publish/etsy`, `POST /api/ebay/publish/:id`, `GET/POST/DELETE /api/ebay/queue`, `GET/POST/DELETE /api/schedule` + +--- + +### 6. Lagerverwaltung (Inventory) + +**Uebersicht:** +- Statistik-Karten: Artikel gesamt, Lagerorte, Aktive Artikel, Lagerwert (EUR) +- Lagerort-Kacheln mit: Name, Artikelanzahl (aktiv/verkauft/reserviert), Gesamtwert +- "Ohne Lagerort" Kachel fuer nicht zugewiesene Artikel +- Neuen Lagerort anlegen + +**Detail-Ansicht (pro Lagerort):** +- Artikelliste mit Checkboxen +- SKU, Titel, Plattform, Preis, Status +- Lagerort umbenennen +- Ausgewaehlte Artikel an anderen Ort verschieben (Bulk) + +**Integration:** +- Lagerort-Feld in Listing-Detail editierbar +- Lagerort-Feld im Generator (bei Erstellung setzbar) +- Lagerort in Historie-Listenansicht sichtbar + +**API:** `GET /api/inventory/locations`, `GET /api/inventory/unassigned`, `GET /api/inventory/location/:loc`, `PUT /api/inventory/bulk-location` + +--- + +### 7. SKU / Artikelnummer-System + +- Auto-Generierung: VD-0001, VD-0002, ... (4-stellig, aufsteigend) +- Backfill fuer bestehende Listings ohne SKU +- SKU in Detail-Ansicht editierbar +- SKU in QR-Codes kodiert (JSON: {sku, id}) +- SKU auf Etiketten und Datenblaettern + +--- + +### 8. Gebuehrenrechner + +**Plattform-Gebuehren:** +| Plattform | Provision | Fixkosten | +|---|---|---| +| Vinted | 5% Kaeuferschutz | + 0,70 EUR | +| eBay Kleinanzeigen | 0% | Kostenlos | +| eBay.de | 11% Verkaufsprovision | + 0,35 EUR | +| Etsy | 6,5% + 4% Payment | + 0,30 + 0,20 EUR | + +- Echtzeit-Berechnung bei Preiseingabe +- Zeigt: Brutto, Gebuehren, Netto pro Plattform +- Auch im Generator bei Preisvorschlag + +**API:** `GET /api/fees` + +--- + +### 9. Kategorie-System + +- Hierarchischer Kategorie-Picker (Baum mit Gruppen/Untergruppen) +- Suchfunktion mit Scoring (Name + Pfad) +- Plattform-spezifische Kategorien (Vinted, eBay KA, eBay.de, Etsy) +- Caching fuer Performance +- Ausgewaehlte Kategorie mit Clear-Button + +**API:** `GET /api/categories/:platform`, `GET /api/categories/:platform/match` + +--- + +### 10. Druck-Funktionen + +**Etiketten (Labels):** +- 2-Spalten-Grid, A4-druckfertig +- QR-Code + SKU + Titel (40 Zeichen) + Plattform + Preis +- Batch-Druck fuer mehrere Listings + +**Datenblaetter:** +- Volle Listing-Details auf A4 +- QR-Code + bis zu 4 Fotos +- Beschreibung (200 Zeichen) + Metadaten-Tabelle + +--- + +### 11. Auth & Benutzerverwaltung + +**Authentifizierung:** +- Passwort-Login (Scrypt-Hashing) +- Magic Links per E-Mail (15 Min gueltig) +- Setup-Modus (erster Admin erstellt Konto) +- CSRF-Token-Validierung +- Rate-Limiting (5 Versuche / 15 Min) +- Session-basiert (3 Tage Ablauf, automatische Bereinigung) + +**Benutzerverwaltung (Admin):** +- Benutzer einladen (Magic Link oder Auto-Passwort) +- Rollen: Admin, Editor +- Benutzer aktivieren/deaktivieren +- Passwort zuruecksetzen +- Login-Verlauf einsehen +- Sessions verwalten (eigene + alle) +- Einladung erneut senden + +**Audit-Log:** +- Alle Aktionen protokolliert (Login, CRUD, Settings, etc.) +- Benutzer, Aktion, Ziel, IP, Timestamp +- Paginierte Ansicht + +**API:** `POST /auth/login`, `POST /auth/setup`, `POST /auth/invite`, `GET /api/me`, Admin-Routen unter `/api/admin/*` + +--- + +### 12. E-Mail / SMTP + +- Konfigurierbar ueber Admin-Panel oder .env +- SMTP-Verbindungstest +- Magic-Link-Versand +- Einladungs-E-Mails mit Passwort +- Gebrandetes HTML-Template (Vendoo-Header) +- Fallback: Console-Output wenn SMTP nicht konfiguriert + +**API:** `GET/PUT /api/admin/smtp`, `POST /api/admin/smtp/test` + +--- + +### 13. System & Backup (Admin) + +**System-Info:** +- Node.js Version, Uptime, RAM-Verbrauch +- Datenbank-Groesse +- Benutzer- und Listing-Anzahl +- Aktive Sessions + +**Backup & Restore:** +- DB-Download als .db-Datei +- DB-Restore per Upload (erstellt Backup der alten DB) +- Abgelaufene Sessions bereinigen + +**API:** `GET /api/admin/system`, `GET /api/backup`, `POST /api/restore` + +--- + +### 14. Chrome Extension + +**Zweck:** Automatisches Ausfuellen von Vinted-Listing-Formularen + +**Features:** +- Verbindung zum Vendoo-Server (localhost:8124) +- Queue-Verwaltung: naechstes Listing aus Warteschlange +- Auto-Fill: Titel, Beschreibung, Fotos, Kategorie, Metadaten +- Vinted-Kategorie-Mapping (337 Zeilen) +- Status-Badge (Connected/Offline) +- Batch-Verarbeitung mit konfigurierbarem Delay (Standard 45s) + +**Host Permissions:** localhost:8124, vinted.de/.fr/.co.uk/.com + +--- + +### 15. Einstellungen + +- API-Keys: Anthropic, OpenAI, OpenRouter (maskiert angezeigt) +- Ollama-URL (Standard: localhost:11434) +- Etsy API Key + OAuth-Verbindung +- eBay Client ID/Secret/RuName + OAuth + Sandbox-Toggle +- Standard-Plattform, Standard-AI, Standard-Sprache +- Verkaeuferbemerkungen (global, in jedes Listing eingefuegt) + +**API:** `GET/PUT /api/settings` + +--- + +### 16. UI / Design + +- **Sidebar-Navigation** (links, 230px, fixed) mit 4 Gruppen: Allgemein, Listings, Tools, System +- **Topbar** mit dynamischem Titel, Suchen-Button und „Neuer Artikel“-Button +- **Dark Mode** (Toggle im Sidebar-Footer, localStorage-Persistenz, System-Praeferenz) +- **Responsive** (Mobile: Sidebar versteckt, Hamburger-Menue, Overlay) +- **Toast-Benachrichtigungen** (2,5s) +- **Browser-Notifications** (Listing gespeichert, eBay Queue, Scheduled) +- **Quill WYSIWYG-Editor** (Toolbar: H1-H3, Bold, Italic, Underline, Strike, Listen, Links, Farben) +- **Live-HTML-Workbench**: WYSIWYG, editierbarer HTML-Code und Plattformvorschau gleichzeitig sichtbar + +--- + +### 17. Installer (setup.bat + setup.ps1) + +**Menue-Optionen:** +1. Installieren (Node.js pruefen/installieren, npm install, .env erstellen, Ordner) +2. Starten (Server + Browser oeffnen) +3. Update (Dependencies aktualisieren, neue .env-Eintraege) +4. Reparieren (node_modules loeschen + neu installieren) +5. Deinstallieren (node_modules + optional Daten entfernen) +6. Status (Node/npm/Dependencies/.env/DB/Uploads pruefen) + +**CLI-Modus:** `setup.bat install`, `setup.bat update`, etc. + +--- + +## API-Endpunkt-Uebersicht + +### Auth +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /auth/setup | Erstes Admin-Konto erstellen | +| POST | /auth/login | Anmelden | +| POST | /auth/logout | Abmelden | +| POST | /auth/invite | Einladung senden | +| POST | /auth/accept-invite | Einladung annehmen | +| GET | /auth/magic | Magic-Link-Redirect | +| GET | /auth/setup-check | Setup-Modus pruefen | +| GET | /auth/etsy | Etsy OAuth starten | +| GET | /auth/etsy/callback | Etsy OAuth Callback | +| GET | /auth/ebay | eBay OAuth starten | +| GET | /auth/ebay/callback | eBay OAuth Callback | + +### Listings +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/listings | Alle Listings (q, platform, status) | +| GET | /api/listings/:id | Einzelnes Listing | +| POST | /api/listings | Listing erstellen | +| PUT | /api/listings/:id | Listing aktualisieren | +| DELETE | /api/listings/:id | Listing loeschen (Soft-Delete) | +| POST | /api/listings/:id/duplicate | Listing duplizieren | + +### Generator & AI +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/generate | AI-Listing generieren | +| POST | /api/upload | Fotos hochladen | +| GET | /api/providers | AI-Provider + Modelle | +| GET | /api/platforms | Marktplatz-Plattformen | +| GET | /api/categories/:platform | Kategorie-Baum | +| GET | /api/categories/:platform/match | Kategorie-Suche | +| GET | /api/price-suggest | Preisvorschlag | + +### Bilder +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/images/remove-bg | Hintergrund entfernen | +| POST | /api/images/watermark | Wasserzeichen hinzufuegen | + +### Templates +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/templates | Alle Vorlagen | +| POST | /api/templates | Vorlage erstellen | +| PUT | /api/templates/:id | Vorlage aktualisieren | +| DELETE | /api/templates/:id | Vorlage loeschen | + +### Publishing +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/publish/info/:id | Publish-Status + Verfuegbarkeit | +| POST | /api/publish/smart-copy | Smart-Copy-Daten | +| POST | /api/publish/mark | Als veroeffentlicht markieren | +| GET | /api/publish/status | Batch-Status-Abfrage | +| POST | /api/publish/etsy | Direkt auf Etsy veroeffentlichen | +| POST | /api/ebay/publish/:id | Direkt auf eBay veroeffentlichen | + +### eBay Queue +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/ebay/queue | Warteschlange anzeigen | +| POST | /api/ebay/queue | Listings hinzufuegen | +| DELETE | /api/ebay/queue/:id | Aus Queue entfernen | +| DELETE | /api/ebay/queue | Queue leeren | +| POST | /api/ebay/queue/process | Alle verarbeiten | +| POST | /api/ebay/queue/retry | Fehlgeschlagene wiederholen | +| GET | /api/ebay/policies | eBay-Richtlinien abrufen | + +### Scheduling +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/schedule | Geplante Veroeffentlichungen | +| POST | /api/schedule | Veroeffentlichung planen | +| DELETE | /api/schedule/:id | Geplante entfernen | + +### Inventory +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/inventory/locations | Lagerorte mit Statistiken | +| GET | /api/inventory/unassigned | Artikel ohne Lagerort | +| GET | /api/inventory/location/:loc | Artikel pro Lagerort | +| PUT | /api/inventory/bulk-location | Bulk-Lagerort-Zuweisung | + +### Papierkorb +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/trash | Geloeschte Listings | +| GET | /api/trash/count | Anzahl im Papierkorb | +| POST | /api/trash/:id/restore | Wiederherstellen | +| DELETE | /api/trash/:id | Endgueltig loeschen | +| DELETE | /api/trash | Papierkorb leeren | + +### Gebuehren & Export +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/fees | Gebuehren berechnen | +| GET | /api/export/csv | CSV-Export | +| GET | /api/dashboard/stats | Dashboard-Statistiken | +| GET | /api/html-templates | HTML-Beschreibungs-Templates | +| POST | /api/html-wrap | Gespeichertes Listing in Plattform-HTML wrappen | +| POST | /api/html-render | Nicht persistierende Live-HTML-Vorschau rendern | + +### Mobiler Foto-Upload +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/mobile-upload-sessions | Zeitlich begrenzte Upload-Sitzung und QR-Link erzeugen | +| GET | /api/mobile-upload-sessions/:id | Upload-Sitzung vom Desktop pollen | +| DELETE | /api/mobile-upload-sessions/:id | Upload-Sitzung schließen | +| GET | /mobile-upload/:token | Responsive Smartphone-Uploadseite | +| GET | /mobile-upload-api/:token | Öffentlichen Sitzungsstatus abrufen | +| POST | /mobile-upload-api/:token/photos | Fotos mit Bearer-Token aus dem QR-Link hochladen | + +### Einstellungen & System +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/settings | Alle Einstellungen | +| PUT | /api/settings | Einstellungen speichern | +| GET | /api/me | Aktueller Benutzer | +| PUT | /api/me | Profil aktualisieren | +| GET | /api/backup | DB-Backup herunterladen | +| POST | /api/restore | DB wiederherstellen | +| GET | /api/etsy/status | Etsy-Verbindungsstatus | +| GET | /api/ebay/status | eBay-Verbindungsstatus | + +### Admin (erfordert Admin-Rolle) +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/admin/users | Alle Benutzer | +| POST | /api/admin/users/invite | Benutzer einladen | +| PUT | /api/admin/users/:id | Benutzer bearbeiten | +| DELETE | /api/admin/users/:id | Benutzer loeschen | +| GET | /api/admin/sessions | Alle Sessions | +| DELETE | /api/admin/sessions/:id | Session beenden | +| GET | /api/admin/audit | Audit-Log | +| GET | /api/admin/system | System-Info | +| GET/PUT | /api/admin/smtp | SMTP-Konfiguration | +| POST | /api/admin/smtp/test | SMTP testen | + +## Slice 07 – Cross-Browser Extensions & Mobile Upload + +- `extensions/chrome`, `extensions/edge` und `extensions/firefox` +- Vendoo Link Icon-Set in vier Browsergrößen +- neues Popup und neues In-Page-Panel im freigegebenen Vendoo-Design +- Plattformvorschau/HTML als Umschalter im Generator und Listing-Editor +- responsive Layouts für Desktop, Notebook, iPad und Smartphone +- QR-Code-Upload vom Smartphone mit 20 Minuten gültiger Upload-Sitzung +- Handy-Fotos erscheinen automatisch im Generator beziehungsweise geöffneten Listing + +## Extension UX 1.4.0 + +- Kompakte Queue-Karten mit Produktbild, Preis, SKU und Status +- Direkte Einzelaktion „Nur ausfüllen“ ohne Entwurf und ohne Veröffentlichung +- Option „Daten + Bilder ausfüllen“ ohne Abschlussaktion +- Explizit getrennte Aktionen für Entwurf und Veröffentlichung +- Gleichstand der Chrome-, Edge- und Firefox-Unterordner + + +## Interaktives Dashboard und Studio Flow V2 + +- eigenständiger Dashboard-Menüpunkt mit realen Kennzahlen und interaktiven SVG/CSS-Diagrammen +- Studio Flow verwendet die verständlichen Zustände Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- mehrere Fotodateien werden automatisch als ZIP heruntergeladen +- Publish-Center verfügt über Retry- und Stale-Render-Schutz beim ersten Laden + + +## Local FLUX / ComfyUI Integration (Slice 19) +- New local image provider option for AI model photos +- Auto provider order: Local FLUX / ComfyUI -> OpenRouter Free -> OpenAI +- Local status route, bootstrap install route and token regeneration route +- Setup menu extended with local FLUX bootstrap + status +- Security concept: backend-to-backend access, optional token, localhost by default + + +## Slice 20 – Local FLUX Full Installer + +- Vollautomatischer ComfyUI-Portable-Installer für Windows +- Dynamische Auswahl des aktuellen offiziellen Release-Assets +- GPU-Erkennung und passende Portable-Variante +- Optionaler FLUX.1-schnell-FP8-Modell-Download +- Fortschrittsanzeige in der Vendoo-UI +- Update-, Start-, Stop- und Statusaktionen +- Autostart über geplante Windows-Aufgabe +- Localhost-only Sicherheitsmodus + + +## Slice 21 – FLUX Install Diagnostics +- Detailed install readiness dashboard +- Real BITS download progress for ComfyUI and FLUX +- Resume-safe `.part` downloads +- 7-Zip path and PowerShell 5.1 compatibility fix +- Install log preview and error source details +- Setup CLI status with component checks + + +## Slice 22 – Reliable extraction status +- Supervised 7-Zip extraction with timeout and activity checks +- Live UI telemetry: progress, elapsed seconds, file count, extracted MB and heartbeat +- Separate 7-Zip output/error log display +- Automatic stale-state detection and safe resume + +## Slice 23 – FLUX Product Reference, Live Progress & Settings Command Center + +- Product photos are uploaded to local ComfyUI and used as a real image-to-image reference. +- Reference fidelity can be configured from 30–90 percent. +- Local FLUX job progress updates across preparation, upload, rendering, moderation and persistence. +- Settings are grouped into General, AI & APIs, Marketplaces and Local FLUX. +- Local FLUX has explicit enabled/disabled/running/stopped/not-installed states. +- Admin actions: install/repair, update, start, stop, status check and safe uninstall. +- Real installation files override stale or unreadable installer-state metadata. +- Uninstall keeps download archives by default and never removes listings, uploads or database data. + +## Slice 28 – Eigenständiges FLUX Studio und VTO-Bereinigung + +### FLUX Studio +- eigener Hauptmenüpunkt und eigene Seite +- freier Textprompt ohne automatische Produktreferenz +- Stilvorlagen, quadratische/Hochformat-/Querformat-Ausgabe +- Seed und Inferenzschritte steuerbar +- Live-Fortschritt aus ComfyUI +- Vorschau, Download und persistente Bildhistorie +- Übernahme eines erzeugten Bildes in den Listing-Generator +- nachträgliche Bearbeitung: Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung +- Bearbeitungen werden als neue Bildversion gespeichert; Original bleibt erhalten + +### Bereinigte Architektur +- keine FASHN-/VTO-Engine, kein Python-/Torch-/ONNX-Stack und kein VTO-Dienst mehr +- keine VTO-Menüs, API-Routen, Datenbankmodelle oder aktiven Installationsoptionen +- alte VTO-Runtime und Autostarts werden beim Upgrade einmalig entfernt +- FLUX/ComfyUI, Listings, Datenbank, Uploads und Konfiguration bleiben erhalten + +## Slice 30 – FLUX Studio Pro + +- persistente SQLite-Aufträge und einzelne Varianten +- 1, 2 oder 4 Varianten pro Auftrag +- eindeutige Seeds, Seed Lock und exakte Seed-Wiederholung +- „Ähnlich erneut erzeugen“ mit übernommenem Prompt-/Parameterstand und neuem Seed +- Favoriten und Favoritenfilter +- Job-Center für wartende, laufende, teilweise fertige, fehlgeschlagene und abgebrochene Aufträge +- Abbruch und Wiederholung +- ComfyUI-Queue, -History und -Systemdaten einschließlich GPU/VRAM +- Wiederaufnahme über persistierte ComfyUI-Prompt-IDs nach Vendoo-Neustart +- phasenbasierter Fortschritt ohne geschätzte Prozentwerte +- reiner Text-Prompt-Workflow ohne Produktreferenz oder VTO + + +## Slice 31 – Advanced Image Editor + +- nicht destruktiver Editor für Generator-, Listing- und FLUX-Bilder +- Undo/Redo und lokale Bearbeitungshistorie +- persistente Versionenkette über `image_edit_versions` +- Original jederzeit wiederherstellbar +- Vorher-/Nachher-Ansicht +- freier Zuschnitt und feste Seitenverhältnisse 1:1, 4:5, 3:4, 16:9 und Original +- Drehen, horizontales/vertikales Spiegeln und Begradigen +- Belichtung, Helligkeit, Kontrast, Lichter, Schatten, Temperatur, Sättigung und Schärfe +- Export als JPEG, WebP oder PNG mit Qualität und Zielgröße +- serverseitiges Rendering in voller Auflösung mit `sharp` +- jede Speicherung erzeugt eine neue Datei; vorhandene Bilder werden nicht überschrieben +- Hintergrundentfernung und Wasserzeichen sind als nicht destruktive Editor-Werkzeuge integriert +- keine Platzhalter für Inpainting, Outpainting oder Maskeneditor + + + +## Hotfix 1.23.2 – FLUX-Layout und kompakte Aufträge + +- System- und ComfyUI-Karte steht unterhalb von Prompt & Auftrag +- Vorschau bleibt separat, damit Bildaktionen beim Scrollen nicht verdeckt werden +- komplettes Job-Center ein-/ausklappbar +- einzelne Aufträge kompakt ein-/ausklappbar +- aktive Aufträge standardmäßig geöffnet, abgeschlossene standardmäßig kompakt +- Wasserzeichen mit Text, Position und Deckkraft im Advanced Image Editor +- Hintergrundentfernung im Advanced Image Editor mit transparenter PNG-Ausgabe + + +## Version 1.24.0 – Responsive UI, FLUX-Archiv und Bildergalerie + +### Responsive Oberfläche + +- Die Listing-Tabelle passt sich auf Desktop, Tablet und Smartphone an. +- Unterhalb von 860 Pixeln werden Listings als kompakte Karten mit klar beschrifteten Datenzeilen dargestellt. +- Filter, Batch-Aktionen, Pagination, Detailansichten und Aktionsleisten umbrechen ohne die Seitenbreite zu sprengen. +- Galerie, FLUX-Job-Center, Einstellungen, Generator- und Publish-Bereiche erhalten zusätzliche Breiten- und Overflow-Sicherungen. + +### Hintergrundentfernung mit Intensität + +- Die Hintergrundentfernung im Advanced Image Editor besitzt einen Regler von 0 bis 100. +- Niedrige Werte schützen feine Kanten und ähnliche Produktfarben. +- Hohe Werte entfernen einen größeren Farbbereich des erkannten Hintergrunds. +- Vorschau und serverseitiger Export verwenden dieselbe Intensitätslogik. + +### FLUX-Auftragsarchiv und Pagination + +- Abgeschlossene, fehlgeschlagene und abgebrochene Aufträge können einzeln oder gesammelt archiviert werden. +- Laufende, wartende und abbrechende Aufträge sind gegen Archivierung geschützt. +- Archivierte Aufträge können einzeln oder gesammelt wiederhergestellt werden. +- Im Archiv lassen sich einzelne, mehrere oder alle Aufträge endgültig löschen. +- Das Löschen von Auftragsdaten löscht keine bereits erzeugten Bilder. +- Aktive Liste und Archiv verwenden serverseitige Pagination mit 10, 20 oder 50 Aufträgen pro Seite. + +### Zentrale Bildergalerie + +- Eigener Navigationspunkt „Bilder“. +- Gemeinsame Bibliothek für FLUX-Bilder, bearbeitete Versionen, AI-Bilder und Bilder aktiver Listings. +- Raster- und Listenansicht. +- Suche nach Prompt, Titel und Dateiname. +- Filter nach Quelle und Favoriten sowie Sortierung nach Datum oder Name. +- Pagination mit 24, 48 oder 96 Bildern pro Seite. +- Mehrfachauswahl, ZIP-Download und kontrollierte Mehrfachlöschung. +- Große Lightbox mit Metadaten, Prompt, Download, Favorit, Bearbeiten und Löschen. +- Direkte Übergabe eines Galeriebildes an den Generator oder Advanced Image Editor. +- Bilder, die in aktiven Listings verwendet werden, sind vor physischer Löschung geschützt. + + + +## Version 1.26.0 – Artikel-Navigation, Publish, Zoom & Extensions + +- „Neuer Artikel“ als eigener Menüpunkt unmittelbar unter Dashboard; Hauptbereich „Artikel“ statt „Listings“. +- Generierungsaktion heißt konsistent „Artikel generieren“. +- Publish-Workspace lädt auch bei älteren oder beschädigten JSON-Feldern robust; direkter Publish-Scope korrigiert. +- Smart Copy wird als zentriertes Popup geöffnet, während die mittlere Arbeitsfläche die vollständigen Verkaufsdaten zeigt. +- Galerie-Lightbox mit stufenlosem Zoom, Fit, 100 %, Mausradsteuerung und Verschieben. +- Kleine lokale SVG-Icons für Auswahl, Favorit und Medienaktionen. +- Gebührenmodell auf private Verkäufer ausgerichtet; Etsy als Shop-Sonderfall transparent ausgewiesen. +- Eigenes Extension Center mit Browsererkennung und Vorbereitung für Chrome, Edge, Firefox und Safari. +- Safari-Quelle enthält Konvertierungsskript für Xcode/macOS. + +## Version 1.25.1 – Adaptive Bildergalerie + +- Vollständig fließendes Galerie-Raster ohne starre Zwei-/Einspalten-Breakpoints +- Bildfläche selbst öffnet die Vorschau; kein separater Öffnen-Button +- Kartenbreiten, Overlay-Steuerungen und Aktionsdock passen sich per Container Query an +- Direkte Seitenauswahl oberhalb und unterhalb der Galerie +- Kompakte Symbolaktionen auf sehr schmalen Karten und vollständige Beschriftung bei ausreichender Breite +- Saubere Tastaturbedienung für Bildöffnung, Auswahl, Favorit und Pagination + +## Version 1.25.0 – Detail-Responsive, Gallery UX, Smart Copy & Prompt Lab + +- Inhaltsbreitenabhängiger Listing-Detail-Editor mit Container Queries, flexiblen Formularrastern und umbrechender WYSIWYG-Toolbar. +- Überarbeitete Galerie-Karten mit eigener Auswahlsteuerung, Favoriten-Overlay, klarer Vorschau und stabiler Aktionsmatrix. +- Smart Copy als responsives Modal statt verlorenem Inline-Workflow. +- Ruhiger Publish-Refresh ohne rotierenden Gesamtbutton. +- FLUX-Bildbibliothek mit serverseitiger Pagination (5/10/15). +- FLUX Job-Center mit begrenzten Seitengrößen 5/10/15. +- Prompt Lab für lokale Strukturierung und AI-gestützte Prompt-Veredelung über den aktiven Text-AI-Provider. +## Version 1.28.0 – Artikel Quality Center + +- Zentrale Qualitätsprüfung aller Artikel mit Score 0–100 und verständlichen Noten A–E. +- Harte Publishing-Blocker getrennt von Warnungen und freiwilligen Empfehlungen. +- Prüfung von Inhalt, Metadaten, Bildern, Preis, Organisation, Duplikaten und Vendoo-Plattformprofilen. +- Bildmetadatenanalyse und persistenter Qualitätsverlauf. +- Gebühren- und Erlösübersicht direkt im Qualitätsbericht. +- AI-Vorschläge für Titel, Beschreibung und Tags mit verpflichtender Nutzerbestätigung. +- Suche, Status-/Notenfilter und Pagination. + + + +## Version 1.29.0 – Publishing Hardening + +Vendoo bündelt direkte API-Veröffentlichungen und Extension-Workflows in einer persistenten Publishing-Queue. Aktive Doppelaufträge werden zusammengeführt; bereits live protokollierte Artikel benötigen eine ausdrückliche erneute Freigabe. Temporäre API-Fehler werden kontrolliert wiederholt. Alle Schritte erzeugen Audit-Ereignisse. eBay-Angebote können anhand der gespeicherten Offer-ID synchronisiert, aktualisiert und beendet werden. Etsy unterstützt Veröffentlichung und Statusabgleich. Vinted und Kleinanzeigen bleiben sichere, vom Nutzer bestätigte Browser-Extension-Abläufe. + + +## Version 1.30.0 – Operations Center + +- Betriebszentrale unter **Admin → System** mit getrennten Bereichen für Backup & Restore, Update Center und Extension-Diagnose. +- Konsistente SQLite-Snapshots über die Backup-API statt direkter Kopie der geöffneten Datenbank. +- Manuelle Voll- oder Datenbankbackups mit Uploads, optionaler `.env`, Manifest und SHA-256-Prüfsummen. +- Sofortige Prüfung jedes Backups einschließlich SQLite-`integrity_check`. +- Rotierende tägliche automatische Backups mit Uhrzeit, Aufbewahrung und Upload-Option. +- Verifizierte Wiederherstellung mit automatisch vorgeschaltetem Sicherheitsbackup. +- Update-Staging prüft neue Version, Pflichtdateien und verbotene Laufzeitdaten. +- Updates und Wiederherstellungen werden ausschließlich bei beendetem Server über Setup-Menüpunkt 11 angewendet. +- Setup-Menüpunkt 12 stellt den vorherigen Programm- und Laufzeitstand aus einem lokalen Rollback-Snapshot wieder her. +- Sichtbarer Migrationsstatus, letzte Operation und ausstehende Operation. +- Optionale Online-Updateprüfung über eine selbst konfigurierte öffentliche Manifest-URL. +- Vendoo Link 1.9.0 sendet Diagnose-Heartbeats; Chrome, Edge, Firefox und Safari werden getrennt geprüft. +- Automatische Backups enthalten niemals `.env`; Update-Pakete mit `.env`, Datenbank, Uploads oder `node_modules` werden blockiert. +## Version 1.31.0 – Mehrbenutzer, Rollen und Server-Sicherheit + +### Benutzer und Rollen +- Rollenmatrix mit Administrator, Manager, Editor, Publisher und Leser. +- Serverseitige Berechtigungsprüfung für Artikel, Medien, AI/FLUX, Publishing, Einstellungen, Benutzer, Sitzungen, Audit und Operations Center. +- Schutz davor, den letzten aktiven Administrator zu deaktivieren oder zu löschen. + +### Sitzungen und Login-Schutz +- Sichere, gehashte Session-Tokens mit absoluter Laufzeit und Inaktivitäts-Timeout. +- Widerruf einzelner oder aller Benutzersitzungen. +- Kontosperre nach wiederholten Fehlanmeldungen. +- Starke Passwortregeln für Setup, Einladung und Passwortwechsel. + +### Zusammenarbeit +- Persistente Artikel-Bearbeitungssperren mit Benutzeranzeige, automatischer Verlängerung und Ablauf. +- Leser und gesperrte Benutzer sehen Artikel schreibgeschützt. +- `created_by` und `updated_by` werden bei neuen Änderungen gespeichert. + +### Server-Härtung +- Standardbindung an localhost. +- Konfigurierbare Host- und Origin-Freigaben. +- Rate Limits für lesende und verändernde API-Aufrufe. +- Helmet/CSP, Request-IDs und restriktive CORS-Antworten. +- Filterbares Audit-Protokoll mit CSV-Export. + + + +## Version 1.33.0 – Docker, NAS und Produktionsbereitstellung + +- Dockerfile und Compose für reproduzierbaren Betrieb +- nicht privilegierter, read-only Hauptcontainer +- getrennte persistente Datenbereiche +- Liveness, Readiness und geordneter Shutdown +- Reverse-Proxy-, Cookie-, Origin- und Erst-Setup-Härtung +- sichere Docker-/NAS-Backup-, Restore- und Updateabläufe +- Windows-Lokalbetrieb bleibt vollständig erhalten +- FLUX/ComfyUI bleibt ein optionaler externer Dienst + +## 1.33.0 – Multiplatform Deployment & UX Fixes + +- Windows-Zielauswahl: lokal, Docker Desktop, NAS/Portainer, VPS/Linux +- Linux-/NAS-/VPS-Assistent `setup.sh` +- neutraler FLUX-Studio-Start und Arbeitsbereich-Reset +- stabilisierte Benutzerbearbeitung und sichere Update-Antwort + + + +--- + +## Plattformarchitektur ab 1.35.0 + +Vendoo wird als modularer Monolith weiterentwickelt. Version 1.35.0 ergänzt einen Plattformkernel, validierte Modulmanifeste, Lifecycle, Event Bus, Feature Flags, Policy Engine und Routenverträge. Die bestehenden Fachbereiche sind als `legacy-bridge` registriert und werden in späteren Releases einzeln migriert. + +Das Design-System verwendet eine versionierte Tokenquelle unter `public/design-system/tokens/source.json`. Light, Dark und High Contrast werden deterministisch in CSS erzeugt. Freies CSS ist für den geplanten Theme Manager ausgeschlossen. + +Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Policy. Unbekannte Policies verweigern den Zugriff. Externe Erweiterungen dürfen nicht im Hauptprozess ausgeführt werden; der isolierte Extension-Host bleibt als deaktivierter Feature-Flag vorbereitet. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FILE_CHECKSUMS_SHA256.txt b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FILE_CHECKSUMS_SHA256.txt new file mode 100644 index 0000000..5a26733 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/FILE_CHECKSUMS_SHA256.txt @@ -0,0 +1,384 @@ +f344f75df655bc133e6c4abb2d215b2234fb22a4bb155c217317c36ee4f0f8fb .dockerignore +196b4f6848e886de83ce63810e60a2b129ff3e4e71f080efb9611432c4b845be .env.example +58e3f4b76cc9398f32dcd5481b2080e7e5ba322aca04b8b8374446299c5d7dd6 .github/CODEOWNERS +5bd335eb750d084ca12b6a3b3f35d0cd7bc454ba24ed280187c66acf75eaf396 .github/dependabot.yml +f65684e98b01d0e0b6bfe1284b5de3354559e4cd51e634fe9e9e2e5473b890e5 .github/pull_request_template.md +f037cc2a7fc97f78646cc834f35ecd28811ac46c960373f24ee2aff8a48558e8 .github/workflows/ci.yml +dea64cf55cb8eb8922b0f1156cbce5f4e853dfbc679d9f7b4058a7fd4bdd34d2 .github/workflows/release.yml +9e7decb6c419fc032644ad361043e69770104ea5904bfcce4942f9416787d975 .gitignore +9bfa732952ad571d06527b32d96d79133fc23fa607df00de440b2802f543608c .npmrc +4c424ef64a09639953388bd2e749d45e9a0d3d533c8b6fce547292237a0a48f9 app/architecture-boundaries.json +a6bdb9b6fdc97a0b2bbc052bd242a9885a75bcc46ee4f0414d8b7e215b6940de app/contracts/module.schema.json +7f38b3ac792e61e030c9f69355e2378f8f61e8e61c7b29c82cd0fa7ec930cfec app/core/themes/theme-contract.mjs +d84a573699e413e0a0803459e0d05c5c4e6e35d3499eb634f95302d151e75c4b app/kernel/errors.mjs +a34333cbc784d39b39a8071a437972c6175c551104dc2d82fc4c0e0506b18259 app/kernel/event-bus.mjs +0e8f691f96e1ef0a7a86d32cae7b7e1db04325fe94a32b8c1f484a5b6950f3b4 app/kernel/feature-flags.mjs +a0223a00691908862aaf1fd9383b6d86d67f26325c509280b8c2daa5cfcee28e app/kernel/lifecycle.mjs +ba1f4f799a508ff113738cce7ac9f5af27739cb60d7f04d7a6c5878de3c5b7f4 app/kernel/module-contract.mjs +1baa99ffd80fffcfed69f21a5db8911500f35c0bd9df23cd02a7fb3b563266df app/kernel/module-registry.mjs +3020a4f447f5af3d77efb918e5d074c79e3badea073e3fe0997bc6cf03bde312 app/kernel/platform-kernel.mjs +7944f68d995a5d70273f8a985973f668fc62a414d85636d92f3774e34d39a37f app/kernel/policy-engine.mjs +8fd120558114a0357d55b070c3a150464f57d6d87a84594c10562774ed994213 app/kernel/route-registry.mjs +0bb099111f7cf49780389d0b078083756a448b663661ee22475d84d38a5f9719 app/modules/ai/module.json +83ad66e0eba88246d292c96fb1ec02b9c59ed15614bbbf4a4a989787fe51e2d3 app/modules/auth/module.json +1e63f30aa7616f5582c2b1d151f5da29ae92dbfdcc5e69d516d8780950debf24 app/modules/catalog.mjs +083db2c78089db389e51e743078c847f1067f3d1a39081080c601d31556f15fd app/modules/flux-studio/module.json +82b31b548c603cdc4e21b50707fd3d21114b85ef1f61db607c881f9ac1809132 app/modules/listings/module.json +6453b41f4074b37c7f7a2c30aa8a0eba8c4233f8d474251919094ed0c15fe38d app/modules/media/module.json +9723a9b12a9b472b2ed8c4635cdce663b2f0f65643d8ae0b6219715d853f740d app/modules/operations/module.json +b71c0f607bbba70b4485fcce883a7d2ab4a533a6dc203eb7de616aa4d8bae435 app/modules/publishing/module.json +61aa66129d18e2c5e6c2e7319de836a4713fde1ddcc31c6a5ca56c22d22d9b48 app/modules/quality/module.json +1f02d199bac34cdfe2e41c32dfd450f2160493d9589b11eb1ca595fba74d5daf app/modules/security/module.json +452a5262c418b393bdd8754fe90f9ea14edf61338f8dcfd6438f0852920f608c app/modules/system/module.json +92d968c993ee5a2afc14df557b7cba552cae9a41722989007eb0b784514333ff app/modules/themes/index.mjs +1e062db871f9bf13b487c972f6cd4dd9cfe1b3046cd9b53ed43e0a0babc9d285 app/modules/themes/migrations/001_theme_profiles.sql +2af572390890e4d793aea9c1356c835917655c225a7848325d5a7c5ae3c08a09 app/modules/themes/module.json +3c3dc5974976672cd0e9141867a8a1d78a0dfaa54582bafb8f360bea571bf11a app/modules/themes/repository.mjs +4e2c348a184384739408894f26b02c56c6bfd4dd531eebb9c67d2fb7fbad3009 app/modules/themes/service.mjs +11b4a1bc94b68998a3a2a8bec766bc56c7ff829939d914b0ce896f963feb10ed app/modules/users/module.json +69ce3be6a8a4a28725c5c7f9dc8803415ab0d1e503047b66dfcc9a44c9e90109 bootstrap.mjs +a29b1b7f6fa480d9d7a801e41df54aab5e8e1e5bde52c69794ef22b59bcfc76d CHANGELOG.md +61823fc9fdc036170cce6edeecbb612b442d1c65fc9f7a7eaba47e5016316ee5 CLAUDE.md +4a3273ec603224a70ceb81c0d5ed6617c3ba2d3111342b4e646a79752f4b3f87 compose.bind-mounts.example.yaml +9f865115da62b2689e06156da34c97e10a0b9831a77f967f7f418e100543d789 compose.vps.yaml +0226a6661724b6a5a88a615dc7955f7c601930b7a3f6f4b328becd0f6866f46b compose.yaml +6323f984f1e04ab135356a14ec151941638041f6b305ed68772d5e1812972075 db/schema.sql +49690c4e4c8e84de8eb40ffe79823493b7be20fe46fa2ad9e6544f7f0afbfd2c deploy/Caddyfile +2e0077cd6dd83d3a07fcef25b7e37ab5fc056dbe1ca034b33d55135234045882 Dockerfile +cc794bca282d79a27d860d0bfef4641769f4e9f31172778a1d9d8205558d948e docs/ABNAHMEBERICHT_1_32_0.md +de0d86f806a70195050613f5b56f6c4da1ee2eff02a9a991b6aacee229c46ca5 docs/ABNAHMEBERICHT_1_33_0.md +947745174897470de0bebf4f7c2b29a0c0ef3f1a762f277374c2614d9e3dec4f docs/ABNAHMEBERICHT_1_34_0.md +26c87fa38f804749a9363812127dc33ef69e201e4ef9b59206f6fe6e15c845b0 docs/ABNAHMEBERICHT_1_34_1.md +f792d366c13c5a4a9b4e8d4054e26190d9c327a7a770005d74bbe2b31bb84ab3 docs/ABNAHMEBERICHT_1_34_2.md +bc638fd21222ada7b15e40adff80a318aebec58139978109cbfd30040815a8d4 docs/ABNAHMEBERICHT_1_34_3.md +c0ae5c4176ba6f7510be047d4feaf383cd646152131003ebccc6bfbf8c8056f5 docs/ABNAHMEBERICHT_1_35_0.md +c050c8e11f2bdb0a742851423dcff864bafba9cc839c52b2f8d48b75f7b18633 docs/ABNAHMEBERICHT_1_36_0.md +e5f065ba8d516a5ebd47e7e6859d0aab40ef69797413e358d969ef27c454fcdd docs/architecture/DESIGN_SYSTEM_1_35_0.md +cc0edf92756a51414563b4407dedb21d1e3b36682f4bfbbc334aeb33a22ae1b4 docs/architecture/MIGRATION_ROADMAP_1_35_0.md +eb47b055dd336e8360ec7c4712dee4ae37f109f3820e3f185969e2327d790cf8 docs/architecture/MODULE_SYSTEM_1_35_0.md +d7773faac0e93510a5a51c91de997df03547b51f3124267dcf137d4e99299562 docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md +771c4771c4d7335750c5a2d976c765b4ed66ed936d8550d7af69de14a16705bb docs/architecture/THEME_MANAGER_1_36_0.md +67a1be91b0c2d2797716f785f2f770c881cf88d7fd617722468df8d949d0638a docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md +2acba8fcc5da42e5a808925a86628bc0c6fc817ddb082b0ead3857c6a43460f1 docs/BRANDING.md +f23c643236c84b0e3715620598f358065748b6d090229003e23df8ad9320b54e docs/FULL_LISTING_EDITOR_2026.md +ba9a9b93622fadc399cd9067d80b4ea339193eb19825106264a3b86b7128d107 docs/GENERATOR_STUDIO_2026.md +54f45ae5c2f12b6314b3f1bc3e59e753c64ca7cfe39644725d765e9f02f637ef docs/GITHUB_DEPLOYMENT_1_34_0.md +5a25e952d7d79b562bc19c990451830ea2ac3e730c9af399425ea18ffb8fd61d docs/GITHUB_IMPORT_STATUS.md +65d19c1576f4a6011008a7e447a3530222517752fb59ebc5bbb506b791e32605 docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md +c9c8f934a0c4192dae29df913588188aab1321364d1c46b9c66da5c754e9df41 docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md +2306ff399000c07a9d7e260a58d92c748fa3219922a4dda25f6b2121a1ad2dbc docs/HOTFIX_STUDIO_FLOW_DETAIL.md +0584257245a29de62a6374eb8b114bd19fc6a36306ed990d820ed2062514d6a6 docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md +7c1afdefd1e3a75a5d6b49aedae80e266bab4e91cbfc657fb862ddfd737dc792 docs/INSTALLATION_1_33_0_MULTIPLATFORM.md +1475e484cccb0db67e02abbb05f8b8e676c85536ec4f5bda7dd99d4563fbc049 docs/INSTALLATION_1_34_0_ZERO_EDIT.md +1df2aed413865c03e541e66249c1df3eed6d3c3aea9f0ffdeaee40453665df14 docs/LISTINGS_UI_2026.md +4f74fef6877126d2579356b9731955e29f05ea8b55b59abb91569e47730a1c3f docs/PUBLISH_CENTER_2026.md +bae236366d6d2cb0139e4dbf8baf099599bbff27aacc9eeeba20fdff644f5fb0 docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md +5223408e51f35ba5f6ce86155544311dd13f60522a6d482f372d64788ab466ac docs/RELEASE_1_24_0_VERIFICATION.json +b66524ffd19976f650c6b26b299898b182355950f545c131de16c89fcffe0ebe docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md +1c5ebf1a496c9571fe9d941793371f9f0be89d85a587c8fa9532f9b25bba92bc docs/RELEASE_1_25_0_VERIFICATION.json +1eef86e13d9429701e9d60894f36fe0eba82c974565b1a6687ed157ca6da680e docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md +14475ae4169f4bde4a5b7ae5512867f972ede69dcb621d7cc6a208d4fdc6a331 docs/RELEASE_1_25_1_VERIFICATION.json +f02b80194f434dd3d82b5c4c111e8cae65f1f053a267cec2793a1ff551daff9a docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md +570522a6513134227122da1b5f2f56a421c62d3b0b5adba703872e9026fa2b58 docs/RELEASE_1_26_0_VERIFICATION.json +4cfc47f35765259cbd6a841f51a85fa86600991d1ca545b10756f15b6d332f7f docs/RELEASE_1_26_1_STABILITY_GATE.md +9646b913994b1bf8ce03eab57e7a6a87061540e44fdde63ad5b7eee93ca9f475 docs/RELEASE_1_26_1_VERIFICATION.json +7c6c3a2e4ce478f47e540a34a3505dbbe11b8f2175d0b274bec7643457997140 docs/RELEASE_1_30_0_OPERATIONS_CENTER.md +6f9c206869ba94c461b1d9f8e40847792b8891bb27809a0c6fa3d1d68a4e8598 docs/RELEASE_1_30_0_VERIFICATION.json +5da62e06d768ce832d18fb3ecb3ccca2f051a6218d2d3cb492d1e5aebbcccb52 docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md +6288a31dfa61f71d13f79d40ab64bb7ca475de07ada154c002cd4b0d44a284dc docs/RELEASE_1_31_0_VERIFICATION.json +4ee88ff8c3c655cb31bc18192a37ffdd0eedf8e26039b252094d5ce169c1883c docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md +628d4d1a6c7d837a0c8c22958253193642ddfeaab5f425f418f9f32b46e84340 docs/RELEASE_1_32_0_VERIFICATION.json +624919ea60969079f767068771800dbc04ea7a87eb1a53d7f0497447492c25cb docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md +3f9ae39792bec600ddd534284ae62cb2b4f9db01fb54c09cee8ae161551caa2a docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md +c724b699894d5ec3c050af0253c6de0c645a789219a344ad7ffe31e330d9921b docs/RELEASE_1_34_0_VERIFICATION.json +7caa9e7b1e51af362332e9c50165b60c54c9219c7384c8313d3735c387df1c01 docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md +ac834ee57ab6bd8fe3dd0238837193e66e01d7e78590a289347b002591da3fb4 docs/RELEASE_1_34_1_VERIFICATION.json +51057d17e2636ef1bdfc6751dfad29ebd34fbe7f41b870f8f2f756a5c97d1c4a docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md +4c2c141ffc85b1d7517672abf2b28bb19fe4769505c0eb4d37e22bae06826cb3 docs/RELEASE_1_34_2_VERIFICATION.json +d55eaec79e10800a930fa030367ffa34037755e5757bc532e72d25711c5ca09a docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md +4bb731af51e786a4827ec48bc7c4d4843c287456aedc09d8b74aa0eed3c0ec16 docs/RELEASE_1_34_3_VERIFICATION.json +e7bb3bb950e3c5303746f43139ca8f7c985d2d73896b44f5b707dd46a9152529 docs/RELEASE_1_35_0_PLATFORM_KERNEL.md +33eb9f0059a2ddc4b4debe5bf733720f2a5bb22f2710bab0a0cc9ac1b898ff11 docs/RELEASE_1_35_0_VERIFICATION.json +5a9fcfa0ea6e9c5c13f6d943e1de4d31c21e21fcff9186e8a714d7a16ed1c18f docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md +2dfe705857424865f101e861f4e633c8a7fb3c0e8dab874cb6620fd09b1ca902 docs/RELEASE_1_36_0_VERIFICATION.json +fe5756f788b2ab131f0832504d90d46d1ed744bfe470fd040803985234afa3cf docs/SLICE_02_ACCEPTANCE.md +05614ecf41becc399acfa6a3b2f681f37a8c80f5ce0130b98c578b2c8c60713c docs/SLICE_05_ACCEPTANCE.md +0838bcc6464a6beff39f0b4b056e513c1a390193521d9a8fc5d440d38034e9d0 docs/SLICE_06_ACCEPTANCE.md +ae0f7c6f6e614ec77e362db1076288596ec48d11b78f33f26908fbc40ae5dd18 docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md +b561983736639a08146d3642b718651907853d589ee32e5f421fc090f2c75a20 docs/SLICE_08_EXTENSION_AUTOFILL_UX.md +c5bf7a0c9f787f100e422d0deb27bffc2c8c86b0e7c1ce1f35d2de8d7d8ea774 docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md +876675bc3015838f568a6970ef6c01c9fcafe1d8d138c4379bc0f2ee1fd7445b docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md +223f50d05096b3bfa3ac3a9f0880a2055fc2029b7d70fabd086612c3348cb34a docs/SLICE_22_2_DOWNLOAD_RECOVERY.md +95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269 docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md +88f5a2d021874764b6a7f56c690a1e2112155b83cdf0d4d964b2c6e00ca756de docs/SLICE_22_3_SHA256.json +791a5d30f0d6ff9664722a2d00dff0db0bbdf2207e83ab391a40d6c4ab913bba docs/SLICE_22_4_SHA256.json +d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807 docs/SLICE_22_4_UTF8_BOM_FIX.md +f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe docs/SLICE_22_4_VERIFICATION.json +e140f3ee3298b3894126e37900b04ff1073ed400e6414e35897815b3f674d93e docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md +b4b01b9f3751940d184d6fff269108798c7ec62ca7796b923cf82abbc142ff3b docs/SLICE_23_VERIFICATION.json +05e3fcf5aa9a0d8d53175a3d24c5feb36e55f73edfeadf05384e87a5de73353a docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md +43d867bb8b455f7450e5146648b28c759e50ebab45855d75a36dbc9f91d3f654 docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md +0bdbeed4b57767427afbebeddc30ea58a211f851663939d6301af38820cf098f docs/SLICE_28_VERIFICATION.json +1e20fd1abea3ce61f7f9cc88c6e8e4ddb0fe3513c5600e41b8f7c3f8d38e3ae9 docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md +1df7305e6a43212a582d5f83630e0ba04fbdd3da0d673d9dcb8f1c5f7ca37731 docs/SLICE_29_VERIFICATION.json +07d1ad0c19567cbdb05623567816f1506197a31e86fbcd1742f4ffa300335ae4 docs/SLICE_30_FLUX_STUDIO_PRO.md +008237388c4816d3cd67f3f0abfd0512f4ebf2dce75ab6790de41b0481ae1d97 docs/SLICE_30_VERIFICATION.json +db1674505e2ca7eb36c8752ea9b2d6fe2f2c12a5e6a1584e2722e20445b05e34 docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md +3133a06e68cd98e615797665956f1bc401c0427045298b46c48ca590be3ef9da docs/SLICE_31_VERIFICATION.json +d425fc1724ac55853bf3433348486de8f8aab111f53fefee3c532beb1deb82af docs/SLICE_32_BATCH_IMAGE_FACTORY.md +0a8917412d000168956f7ac55e60ab79b54a8834deef0386a681bb8f920514cd docs/SLICE_32_VERIFICATION.json +25ff8492360d473e99c75c308be5f75cf84e0c11574d42aa6553d44f8bb35582 docs/SLICE_33_ARTICLE_QUALITY_CENTER.md +1d5411f69c62222d3155f0e89f2b4b5a3effe317ea1e59347efa7730a8e1b0e9 docs/SLICE_33_VERIFICATION.json +1161decfabd73c40ff1d2ffbc7d1b9b5168a8fb5fa392187c1b431e9a7f3a78f docs/SLICE_34_PUBLISHING_HARDENING.md +aa7ebc90470071566749c61b0dece4169f87666dcca4b88922f7fbafcfdd5a84 docs/SLICE_34_VERIFICATION.json +cc77a92ca268d3c444c6113a9cfaa38d3c805a579f6b1b21b7dd43a6ada58551 docs/superpowers/specs/2026-07-06-autolister-features-design.md +4b66a14b4515e38477f10325ab9d03d9782ad0d426845aefe2d508986ed2cd81 docs/UI_FOUNDATION_2026.md +5e655fcf3bb1182f8850b4104b56697ea952b01d2698af6d3d40b05e338b3506 docs/UI_UX_FIXES_SLICE_03.md +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extension/background.js +b86f00c7afa1dc921dd66f5cfea8c817eebc8876b9daca6a55dfd03680156c79 extension/content.css +8ee8d1a94bbf83e5d09aee3c7d8c4ea5f216b2a51e73b6dbc74f7e6b99017a1c extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extension/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extension/manifest.json +e61fd8e1264995fd633bd76e71329813dd02f361286329832a18dfd5a8bdd372 extension/popup.css +bdf5b794473cae818789cf95a28a9c9a9d50f81529d72fdf45a536247a783922 extension/popup.html +d7893cfdd686d4a6a1bea333468d4ba72cfff0e7166687181a2d37ba1faf3332 extension/popup.js +c7db96df5fed3c8336062204322d62b4870f5c8ab1d30ea48cc6e7e38ed3513b extension/README.txt +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extension/vinted-categories.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/background.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/chrome/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/chrome/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/chrome/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/chrome/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/chrome/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/chrome/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/chrome/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/chrome/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/chrome/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/chrome/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/chrome/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/chrome/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/chrome/vinted-categories.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/content.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/edge/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/edge/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/edge/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/edge/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/edge/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/edge/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/edge/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/edge/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/edge/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/edge/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/edge/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/edge/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/edge/vinted-categories.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/firefox/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/firefox/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/firefox/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/firefox/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/firefox/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/firefox/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/firefox/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/firefox/icon.svg +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/firefox/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/firefox/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/firefox/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/firefox/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/firefox/vinted-categories.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/manifest.chromium.json +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/manifest.firefox.json +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-chrome-1.9.0.zip +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-edge-1.9.0.zip +74c219587cf0f827a1a1e2b8adacac55477be717fd4ae80e2ae027a9ffb0744d extensions/packages/vendoo-link-firefox-1.9.0.zip +9e828844f41154c87ccfc5d8f3a19476396e2782fbd0f956465b42fb508cb098 extensions/packages/vendoo-link-safari-1.9.0.zip +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/popup.js +48b5f9f4ecc1c1c42f37321e9bb2fc857c646b9c16c34520fc7d4ca59a1036ac extensions/README.md +0b2a03d06faae7aaebeee8ec9fe1864422c8651f17ae90b185fd31474ac5eeb0 extensions/safari/build-safari-extension.command +afd76deccd8bcf26d39f8c86deb1de578873ddd8b09ac18eebacae33169bb420 extensions/safari/README_SAFARI.md +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/safari/web-extension/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/safari/web-extension/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/safari/web-extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/safari/web-extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/safari/web-extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/safari/web-extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/safari/web-extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/safari/web-extension/icon.svg +772f1e2e33f4d6d882fa0af663e315fd3b8208141227bb5beb9baaf1f4e08397 extensions/safari/web-extension/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/safari/web-extension/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/safari/web-extension/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/safari/web-extension/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/safari/web-extension/vinted-categories.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/vinted-categories.js +3a72c2ba86fca6655af49a47e853a335532586dbf5ea620d909570ec7d76cb02 FEATURES.md +7f78a0d512de717ba76c9a5e3e1bd2e52db9edf146e8eeb7297458fde5d92afa GitHub-Deploy-Assistent.bat +9a5bfcafdce53130d7bc36695090a8f87096b4ead9dd5ccc3d46d1d80c07a0fa lib/ai-claude.mjs +b53b7a8dea3bd46bee9c32e2ea840d618b5cb31cceba49937c057ec615fb87b5 lib/ai-flux-prompt-jobs.mjs +347778ce521a1919ec0679918171dc625d7f04d6dec26c8a4700e702bc78de67 lib/ai-local-images.mjs +aeb5103c0179232b9954b7854be8099cf4bb0f3ef1ee908eb5e4faf6a8f1205f lib/ai-local.mjs +cad43695655fc1c805815d9d694f23bd41124c1b78bb6493e7fea8893c464320 lib/ai-model-jobs.mjs +9f474f665d6d610fe00d9e707e85314a854abb906e31db2bb9a3a7c76785ff4f lib/ai-model-photos.mjs +3fb484456b9e6374f14b53a2276eea8533642b7c9073bb73a1bb0dbed7048bbd lib/ai-openai.mjs +581c10b8346d0aa10169ed23ed49c038951ecd5cac014722c1080bbd5aec1c5b lib/ai-openrouter.mjs +271696d7cbef3581b667a8ce3b07d0b17b1e8b467fbede4d8aa512ecc5225515 lib/ai-router.mjs +76a2f1fdf60053739d82962264219060807022dfc9a2b3a644946fddff02a8d9 lib/archive.mjs +ee6aa662a1875f9df8da1000804a6061f1a0f52c7a266df3d515cab4d410465b lib/auth.mjs +3e2b75c0758703e590233cbcc9b589e3177134accecf5ed921791891cbfc8522 lib/categories.mjs +86793fb57a1dad32f4d93e34c27ffe9e77a95785dd30b2b55ffb67d0ad5e14fb lib/config-store.mjs +8c203e70e9ab07b996d4a53031892bbf37b10efd024f9e0013266cf2af0244a4 lib/db.mjs +75827cb79cebc95cf9ae25469efe28c04cebac2900b62f731552329f0c055e91 lib/ebay-api.mjs +b4460619cb14a4e4b810adc78bdd662c9d375c483c979ef619f8b95dfefd3f73 lib/etsy-api.mjs +5b2c566ff14fa502ba2cd4d4c918fe238a8e93c3283d521144093a897f9d8018 lib/fees.mjs +7dcb2fb5606140afb7a019094d20ef88e9b4e2c70041f77b06d67d8992fd25ce lib/html-templates.mjs +844f0f8bc1de849bacc8df5d57006d5fc1100c706acc1140939a981dfdf2df4b lib/image-batch-jobs.mjs +5ca5fc65a7de7c27243646d291dc42c0b5267a47705a09b306cd2c2eae2a77c8 lib/image-editor.mjs +5e7aa66faddd3d19bc6ebb9ddc13abfc4ab4f17d8c51bb39b2c4e0279385d788 lib/images.mjs +0248ef19a04e8f1265cf89c170875c3a8051c91c6151b762a849cb55104e93f3 lib/mailer.mjs +ad53b2b39f894a685f79e723b3f2b987fc9d9a3b75d39d0059ad225bc36c08cf lib/operations-center.mjs +78d30585aac7dc663e803a19927c89ab02414c0495d81df503e28f7bae58701a lib/publishing-jobs.mjs +3a728417dd668d60fd1526ec738cb9b302f86c01d62b2227f12b2e44b7d389c7 lib/quality-center.mjs +faa5d84ca6b26d1602e53a402de74a5c9e3f9ef9a72233159101f2b0bb0e499f lib/runtime-paths.mjs +cbd60791ee86fe09f07fc9ed85be3654f7aa5a310baafd9053440952619e2bc9 lib/security.mjs +ddb9c7677600795a4adf8a19a7525b7551455459d16eed4cdfbe751ef9ddf0eb lib/zip.mjs +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 local-ai/start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 local-ai/stop-local-flux.ps1 +5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265 local-ai/workflows/vendoo_flux_schnell_api.json +302ab1609d08b3059ba6563b676c64f0709651f2e3c531857bfca3840c535a79 MockupDesign/AdminDashboard.png +49f7625a1299a16576feabfd7824ec3511391bd9b7c53a1fba9232da7e8bd6c6 MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png +7b8cc9c5db8718b0b653ec6aa9d2a8bfa1bba03810248c3ba17745b6f15cc412 MockupDesign/Dashboard.png +4e3e797ed5d3ed0b60d00d8e567fbe51543c08578b858ef71e77b274ae449fd0 MockupDesign/Einstellungen.png +c78fd6f673c0e59789ea313396b12c2f21f75a2ee6c7ad2de81a6b89f6d90588 MockupDesign/ExtensionDesignMockup.png +ed20bc04cab8e85245a855810a61b736a682f972eb85e23340edc9b558851a93 MockupDesign/Heute StudioFlow.png +1ad88f45f377ab9b3f35f96a0ceaaa7f75cb0a996e83370d76c54f527392a08d MockupDesign/IconLogoMockup.png +0e8b4a31d075f1f83ea62b5eeeb53e89859e395a696deb43155932a23e7cef86 MockupDesign/Lager.png +b1c62e118d9be316274e966c558cdcccdd41afb3a9f05a60ec1e4a3433d2ca0c MockupDesign/Listings.png +a52983b9ea2bf920d115485d10bc30226da88bc5cba8b00bebf801b21c096346 MockupDesign/Publish.png +8c8bf278d0c6725e1dbb97b3c3d0c36a9bfc0753c3c86433fdc294bec70dbc0e MockupDesign/Startseite.png +62f14221b1d71c9e631efdadd221397ff657adae4b737df52c8bff1610b90999 MockupDesign/Vorlagen.png +2571f42e92a40e5483c762b1489b38294627dccfeecf388ac2b1156d82666d8b package-lock.json +098e7758e0cf9fa92d2ab1e9c01c62e59196576c79d845292b8a36ca4ed6960a package.json +3da71dd28cb25c7c7317b9061855d40c6a3ab8bc39391963b49ee1e707f838e5 platforms/ebay-de.mjs +cc1ca2b4384c8c548bf755dc33101d248541bf893c3d5f35369961f904afcc00 platforms/ebay-ka.mjs +fa52b247dd758aeee06120499bd1ae6c53e9f460f8bd7762b8f06141b201e32b platforms/etsy.mjs +5c925e3934a4c9e2f26dc15b3449a249f5ce20ecebbd459b15365a34804824d6 platforms/vinted.mjs +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/android-chrome-512x512.png +90689f16d004d8ddd3ae4edd5ec0ca1bde89901fb36de163aadd10d710bf347c public/app.js +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/apple-touch-icon.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/favicons/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/favicons/android-chrome-512x512.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/favicons/apple-touch-icon.png +a0f1e01de4e8215e690c1ce869fecaae9d7ba3cc95883340a53ff38f6be52ce0 public/brand/favicons/browserconfig.xml +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/favicons/favicon-16x16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/favicons/favicon-32x32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/favicons/favicon-48x48.png +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/brand/favicons/favicon.ico +2daa93cca0ad2c0cab90a9e8ad4ea0ae4b64feeb68c422954020d17e2376a381 public/brand/favicons/html-snippet.txt +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/favicons/mstile-150x150.png +c372675942caa5010340f21fe521adc72af537d909008731976820fd84604d42 public/brand/favicons/site.webmanifest +b2dc767e7a01395d23a7f3f5915324db1a7d1912bac60470d2f7c10815895173 public/brand/icons/vendoo-icon-120x120.png +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf public/brand/icons/vendoo-icon-128x128.png +d276f50b298e4dfcbba9147550463aee498531280399167d1e9774aa8e5f7e21 public/brand/icons/vendoo-icon-144x144.png +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/icons/vendoo-icon-152x152.png +47122dc91010721e2692d11986f0b91d9b76634130a95723e92b2a070268d16f public/brand/icons/vendoo-icon-167x167.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/icons/vendoo-icon-16x16.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/icons/vendoo-icon-180x180.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/icons/vendoo-icon-192x192.png +3f2517cfd215cb624ed639d60b064dfa5e88ce71eb7bbebbbfaa70b3d62556db public/brand/icons/vendoo-icon-256x256.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/icons/vendoo-icon-32x32.png +cae3eb737c8ad3a82a679b09d33d991b501bf482e0132e99aa1b8ad064e472b4 public/brand/icons/vendoo-icon-384x384.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/icons/vendoo-icon-48x48.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-512x512.png +bc85af557be532dcce6beb82cf9bd61af683666b00fb61e4b2069f78e60a9a33 public/brand/icons/vendoo-icon-64x64.png +01d59b665bc90dddbd34221e3a2ec3af11bff6fb328aab2c9947c03d74b0465d public/brand/icons/vendoo-icon-72x72.png +ed3dfa59e989b154fac88a3c72ac840290ee3e1565d44aa75596bf0055c74020 public/brand/icons/vendoo-icon-96x96.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-original-1254.png +8fd9901e07dc9172d162706511ac8dcce220a4c907cb3efa9940c9c13fa32526 public/brand/logo/vendoo-logo-cropped.png +3f39371abd1efa6cc8366aac9a6d067f3339b8f2a2da77ab8a26c6bdcf8c4fce public/brand/logo/vendoo-logo-original.png +b56d07515f6220de7fd0cb149fed598a7361c985ae6ca7cb14329a99e8947aee public/brand/logo/vendoo-logo-transparent.png +e83921d2af9930ab27df1abe64374cb9311189bfaaa951068109177443571a07 public/brand/logo/vendoo-wordmark-transparent.png +1711a9387f2d6e75797b77c8daa92b94d7a15e128476e4b8aa9d54ccb0692a0e public/core/api-client.js +f30395399a5c6d7e0a884b22f28f7648e6343695cee9960d6ff13b34de7b48f1 public/core/frontend-module-registry.js +fb3dbcd03f7ed15b773b582c2a2b785c3040d3319aba5b7c682a7e74e1cf3b4e public/core/safe-dom.js +38aa57d13aeb3ab70babcb903bfee71e47d80932ed24a73267c13ff81c24e3ba public/design-system/theme-contract.json +308f1c0e2ed1de34aa11545a1a660aab02631e422d6c6945e3203d11666784fc public/design-system/theme-runtime.js +4d69e2cb6b7f9f63b54d708a5d8b1ebefcdcb9f88a977053db5031c05b098464 public/design-system/tokens/source.json +4a9e65edeaee3421edc3dd4831230062494f1a42f8495f4cb979fde8664d06c4 public/design-system/tokens.css +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/favicon.ico +6e5ba2d8fbe559861428ccf4ebe7e8d1c82357d8b29b7548e40398a605f17c28 public/index.html +141c261b2d0ea713af140d2e89f98b9c9046584bef60e97d8bf3d6b6f504efa2 public/login.html +59291f75eca405e3856bafea76cc601d681060eb6b36bf82a4be3c3ab7eac290 public/mobile-upload-expired.html +f046f166d89fc32774b87deb02e6d144fd2f40c0925aac67487e5b7c489a0ec1 public/mobile-upload.css +6152e4fe715efbf8fd6a7c4286f23172bc3cf15dde085e5e4f7389ad5e65ad73 public/mobile-upload.html +f13846801a8493aa43df6ec3d78b826b21b59e8f21d47735f68fd0b278e440c7 public/mobile-upload.js +01f2bd124257220005032bf234d122b9942fdd8fd57ed8195748b2fb1f5a5cfb public/modules/README.md +2d7a004db3e22b30ad3a682ba445bcee3fc1c5f0ed5e8fee45b5892c5b335e51 public/modules/theme-manager/theme-manager.css +0edc61f0c2bb604c3adc40813b18fe5ca8d9b846ec2a3d9f406ae26ae313d3ee public/modules/theme-manager/theme-manager.js +a26f11bfb9627e878961feacf6d56211a40746731a63a7649bb327a383d60639 public/style.css +ff525e0af963e6f54636cfdc1bd2d045aaf82e7b0199755eb5cdf4c52110be2e public/vendoo-icons.js +da56aefa73aaa46fa2c1931ddc8075028b75fae68def3a03d2af98c4b1d8db65 README.md +deb1a45f6414a2357046ff1d66fc58114549ba5ad695ed40fcceef2aef9f64a8 scripts/docker-backup.sh +20e7590a7633b6c61086a5596d0bec1d343f0ff0eecc444ac932130ff363e258 scripts/docker-entrypoint.sh +be72c39f00415cd57b69835346f00043841ce9f853b10436eb1799ca42980cb5 scripts/docker-restore.sh +3f1005ade493905e37f90314e8b4667189f28297acf88575fee3d2fa5df00216 scripts/github-deploy.ps1 +1461b07a51d41c72be59b3903d36937d5912cadd600d8519142c47478d0d40c3 scripts/github-deploy.sh +0a9bafc79c1c5d59fb9b51ebc34c00eec4a4e9a5e873ce1818f50f3ab983da70 scripts/install-local-flux.ps1 +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 scripts/runtime-start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 scripts/runtime-stop-local-flux.ps1 +1a5736cf5fccc991df122163977c2eac80281bd99a54a5680b3d7eae99e4b79d scripts/start-local-flux.bat +3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd scripts/uninstall-local-flux.ps1 +332011f0281e4fb21397ccadf6e7e37ceef13242ad43e5a87ad55fa625b3009c SECURITY.md +aa58e3380d0e5c8e5df2bf7d09dea4cfca996ecc1991c11e2518e840f72b8bf2 server.mjs +a7384ecbf3977245d31097ea4ff224142d998c3e80d0e44f49aaa8a2d4395728 setup-gui.ps1 +cf4519ce5adc01f05458e3286e86f3a419cceaff5fcdd869a1d07f1c1d1a0d8c setup.bat +5b3d8fb189b7efb656a64fc41c1bf0cde2202ac91aaefbea74c927acd9d9727a setup.ps1 +bf3bf58517beaa27e837f9b5c75d0149b640bba8cf4df6f0a6b2566fd36d7193 setup.sh +2851c914a1b50a036cb265ddd32e5545cc7e8e34ba9d682445926acc23eb8f28 THIRD_PARTY_NOTICES.md +90485a16e3468cb9a6750646700fbb036cb9ac410e9b8bd5c582f7b923bf3c31 tools/build-release.mjs +ac8440bce2659a035302609722f1dfed5d125223376cd575ffc0636397c94b54 tools/generate-design-tokens.mjs +294659f2beb607540abbc3d6b4a1658525448a42f8880f2b282081a259c4e35b tools/prepare-git-staging.mjs +8f9ede5f112ee03323db3a6c73aee00b7037f5139dad241b11baf5f0e9cee4e0 tools/verify-config-store.mjs +5752f6edb956696781da8bddb1c1848d26941fbed5a524691653c84c88c34bff tools/verify-db-migrations.py +3745de55a110d8254983b096c6afab196ddaec7f1153cd2f34c1e480b15aa33e tools/verify-deployment-1.32.0.mjs +44f6b349b722e5a83d4725c4879fa1a9383f59ab5634c49d3d7d9d7aa0cd5e12 tools/verify-deployment-1.33.0.mjs +cbd91b45d97e11bd4f5ca2f9d2edf2b5931cb2aa336cb71afc30aad9cffbe1e0 tools/verify-deployment-1.34.0.mjs +01a43040a95cdac40703f49031acd9267cdba99d9027a51718f35ffb643237fc tools/verify-deployment-1.34.2.mjs +906767e574c06aedebb2d7797150bda2f1e4c4536e8f82f4c7de2e3cc8398c8e tools/verify-deployment-1.34.3.mjs +71d00227a59592cc0ad8ba321c63350ec25748ef965d8cd0eff1317738c7fba8 tools/verify-deployment-1.35.0.mjs +b65eec7fd54e1459b363d99d20f9438ae5bc4b518e775de8b97e33e89a382e42 tools/verify-deployment-1.36.0.mjs +1f052446f6c9627420afeb48aa09771b61f9e08ba573f6a0651b2843ef0b79f8 tools/verify-git-safety-regression.mjs +5bba3d7ae042b0bcf4eb56bacb3f6e41e1b0e00cadd7666505792ba6748cc987 tools/verify-git-safety.mjs +2f8d687319619240efe5849c276f7723e69755c66aa3b9609e41fdf0cda01e55 tools/verify-git-staging.mjs +1d4351e4a827e718766a1677ddfbc3771a91942d0d5a93753e39f1b99c68a4dd tools/verify-hotfix-1.23.2.mjs +44c95844422be3deaad6054ee3f0cced9c25986815d976e0c64b042d0c2c5e1e tools/verify-installer-shell.sh +cab03e98687af87f359f6424d440fc0b644da97b3c614509b8c7d008d4b18a8d tools/verify-platform-architecture-1.35.0.mjs +732bb15431746a829e45ae55c695360cbf1dd21d4fc3be26a98f35708d571e84 tools/verify-platform-architecture-1.36.0.mjs +36e35fedb08923c22fd8551389d33432b66dbd4215d95c594ef29bfc790c98e0 tools/verify-release-1.25.0.mjs +89621694b7bad18b6f3f1ac1956032f64112c40d3edc069ca581dc813d57d628 tools/verify-release-1.25.1-static.mjs +c908f5186cf2d71ef83d5e649327ae19965568933acfb32057ca00d29cf890bc tools/verify-release-1.25.1.mjs +e31a055d2ebb8e439f5b0e0235a71dacecb891e299377cfbbcfa8a32f53a85b0 tools/verify-release-1.26.0-static.mjs +7e107b5f9a1b4ee381ff31105feb6d38dda5c40e4afe105a955badc36e6b8d10 tools/verify-release-1.26.1-static.mjs +d9a75c45f40d857c84790d51dca2fd088c3851ca250405871a7e32773918c66e tools/verify-release-1.27.0.mjs +3989e3ec236c1aa4c387b26cc15e04cf200a018e29c095321acf826c6bae4d66 tools/verify-release-1.28.0.mjs +7d196d913a697248ab1233907fed56c10329cf201e3bdc173bca73cb02755369 tools/verify-release-1.29.0.mjs +3f30260dabbdf0fa7b042381cb9e24f840d0c3ddbc44fe309763789a7d14afb7 tools/verify-release-1.30.0.mjs +b1ceed5dfc17dbd3b038b7168e61076e4d39a0f1d67e2b87677a91c4dbb2daff tools/verify-release-1.31.0.mjs +a372258b97aa68d59a1fe0fd3f380d019c59412d5c408a6064f437d5cfc6e709 tools/verify-security-1.31.0.py +31a120622ce8bb8451014f2662366f81abd28d0f3be59adfe35d17e0ada1838f tools/verify-slice30-mock.mjs +20957a4a038b9afdb01a8c98b881644daf793d2a6bb778a4c8e04780687d00e4 tools/verify-slice31-image-editor.mjs +21061ed5dc88feef48e80efffd9a1054d38598dce5a5d3f9f6bde39b539b83f0 tools/verify-slice32-batch.mjs +0f05b1ac3b686cf535bcfb3e6f6df24a29c3429ade1b3c666c1b99abb1a7311b tools/verify-slice32-image-render.mjs +c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 tools/verify-theme-manager-1.36.0.mjs +0f3f684019763a14f5fbaec42e42c680cf748d930d46cd607fc61eb413ca4553 tools/verify-ui-contracts-1.26.1.mjs +d677548be9b02ebd3b53e93158aeb740a11bd3e6d52b07898f1908c74a22294f update-manifest.json diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/GitHub-Deploy-Assistent.bat b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/GitHub-Deploy-Assistent.bat new file mode 100644 index 0000000..65b2213 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/GitHub-Deploy-Assistent.bat @@ -0,0 +1,14 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +title Vendoo GitHub Deploy Assistent +cd /d "%~dp0" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0scripts\github-deploy.ps1" +set "EXITCODE=%ERRORLEVEL%" +if not "%EXITCODE%"=="0" ( + echo. + echo [FEHLER] GitHub-Deployment wurde mit Exit-Code %EXITCODE% beendet. + echo. + pause +) +exit /b %EXITCODE% diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/AdminDashboard.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/AdminDashboard.png new file mode 100644 index 0000000..de4f1c6 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/AdminDashboard.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png new file mode 100644 index 0000000..ba31be4 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Dashboard.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Dashboard.png new file mode 100644 index 0000000..4a388a8 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Dashboard.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Einstellungen.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Einstellungen.png new file mode 100644 index 0000000..7adb09c Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Einstellungen.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ExtensionDesignMockup.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ExtensionDesignMockup.png new file mode 100644 index 0000000..ae1f76b Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/ExtensionDesignMockup.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Heute StudioFlow.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Heute StudioFlow.png new file mode 100644 index 0000000..a3d3c85 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Heute StudioFlow.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/IconLogoMockup.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/IconLogoMockup.png new file mode 100644 index 0000000..d731ebf Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/IconLogoMockup.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Lager.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Lager.png new file mode 100644 index 0000000..891ee35 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Lager.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Listings.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Listings.png new file mode 100644 index 0000000..9e6679b Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Listings.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Publish.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Publish.png new file mode 100644 index 0000000..47aab98 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Publish.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Startseite.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Startseite.png new file mode 100644 index 0000000..77735fa Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Startseite.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Vorlagen.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Vorlagen.png new file mode 100644 index 0000000..2e93bb3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/MockupDesign/Vorlagen.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/README.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/README.md new file mode 100644 index 0000000..dff9e1d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/README.md @@ -0,0 +1,311 @@ +# Vendoo 1.36.0 + +Vendoo 1.36.0 führt den ersten vollständig nativen Fachbereich auf dem Plattformkernel ein: den **Secure Theme Manager**. Benutzer können ihre persönliche Darstellung über geprüfte Eingabemasken wählen; Administratoren verwalten sichere Theme-Profile und den Systemstandard. Freies CSS, JavaScript, URLs oder Dateipfade werden nicht akzeptiert. + +## Theme Manager 1.36.0 + +- Light, Dark, System und High Contrast +- persönliche Theme-Auswahl pro Benutzer +- UI-Dichte und reduzierte Bewegung +- eigene Theme-Profile auf Basis eines geprüften Basisthemes +- Live-Vorschau ohne sofortige Speicherung +- serverseitige Token- und Kontrastprüfung +- Import und Export validierter Theme-Profile +- administrativer Systemstandard +- Audit-Protokoll für Änderungen +- 42 Design Tokens einschließlich Komponenten-, Fokus- und Zielgrößen-Tokens + +Die Theme-Verwaltung erfolgt vollständig über Vendoo. Es ist keine manuelle Bearbeitung von CSS-, JSON- oder Konfigurationsdateien erforderlich. + +## Plattformarchitektur ab 1.35.0 + +- Kernel: `app/kernel/` +- Modulmanifeste: `app/modules/*/module.json` +- Architekturgrenzen: `app/architecture-boundaries.json` +- Design-Token-Quelle: `public/design-system/tokens/source.json` +- Theme-Vertrag: `public/design-system/theme-contract.json` +- Architektur-Gate: `npm run verify:architecture` +- vollständige technische Dokumentation: `docs/architecture/` + +Die zwölf registrierten Bestandsmodule stehen bewusst auf `legacy-bridge`. Das zeigt transparent, welche Bereiche bereits einen Vertrag besitzen, aber noch schrittweise aus `server.mjs`, `public/app.js` und `public/style.css` herausgelöst werden müssen. + +## Einfachster Start + +- **Windows lokal oder Docker Desktop:** `setup.bat` doppelklicken, Ziel und Netzwerkzugriff auswählen, **Installieren** anklicken. +- **Linux/NAS/VPS:** `chmod +x setup.sh && ./setup.sh`, danach den geführten Assistenten verwenden. +- **VPS:** Domain eingeben; der integrierte Caddy-Stack übernimmt HTTPS automatisch, sobald DNS sowie Ports 80/443 korrekt auf den Server zeigen. +- **GitHub-Erstimport:** `GitHub-Deploy-Assistent.bat` starten. Der Assistent prüft Secrets/Laufzeitdaten, erstellt einen Release-Branch und öffnet die Browser-Anmeldung für Git. + +## Sicherheit + +- `.env`, Datenbanken, Uploads, Backups, Logs, Operations-Laufzeitdaten und FLUX-/ComfyUI-Modelle werden niemals committed. +- Der Erst-Admin-Code wird zufällig erzeugt und auf Windows in die Zwischenablage kopiert. +- Öffentliche VPS-Installationen verwenden ausschließlich HTTPS und veröffentlichen Vendoo nicht direkt auf Port 8124. +- Updates und Betriebsartwechsel löschen keine persistenten Daten. + +--- + +## Vorheriger Stand 1.31.0 + +Vendoo 1.31.0 schafft die Grundlage für einen kontrollierten Mehrbenutzer- und Serverbetrieb. Rollen und Berechtigungen werden nicht nur in der Oberfläche, sondern an jeder geschützten API erzwungen. Artikelbearbeitung wird mit kurzlebigen Sperren koordiniert; Sitzungen, fehlgeschlagene Logins, Audit-Ereignisse und Rate Limits sind nachvollziehbar. + +## Rollen + +- **Administrator:** vollständiger Zugriff einschließlich Benutzer, Backups, Updates und Sicherheit. +- **Manager:** operative Verwaltung von Artikeln, Medien und Publishing, ohne Benutzer- oder Systemadministration. +- **Editor:** Artikel, Bilder, AI, FLUX, Bildproduktion und Qualitätsprüfung. +- **Publisher:** Artikel lesen, Qualität prüfen und Veröffentlichungen ausführen. +- **Leser:** reiner Lesezugriff. + +## Sicherer Netzwerkbetrieb + +Standardmäßig bindet Vendoo ausschließlich an `127.0.0.1`. Einen privaten LAN-Zugriff aktiviert der Installationsassistent nur nach bewusster Auswahl und setzt die erforderlichen Werte automatisch. Für öffentlichen Zugriff verwendet der VPS-Assistent den getrennten Caddy-Stack mit HTTPS; Port 8124 wird dabei nicht direkt veröffentlicht. + +## Bestehende Funktionen + +Alle Funktionen aus 1.30.0 bleiben enthalten: Operations Center, Publishing Hardening, Quality Center, Batch Image Factory, FLUX Studio Pro, Advanced Image Editor, Galerie und Browser-Extensions. + +## Betriebszentrale (1.30.0) + +Unter **Admin → System** stehen jetzt drei zusammenhängende Bereiche bereit: + +- **Backup & Restore** mit SQLite-Snapshot, Datei-Prüfsummen, Integritätsprüfung, Download, Rotation und Wiederherstellungs-Staging +- **Update Center** mit Versionsprüfung, Paketvalidierung, Migrationsstatus, Sicherheitsbackup und bewusstem Offline-Anwenden über `setup.bat` +- **Extension-Diagnose** mit Paketversionen, Heartbeats, zuletzt verbundenen Browsern und Live-/Offline-Status + +Update und Wiederherstellung ersetzen niemals während des laufenden Servers Dateien. Nach erfolgreicher Prüfung wird eine Operation vorbereitet und anschließend bei beendetem Vendoo über Setup-Menüpunkt 11 angewendet. Menüpunkt 12 stellt den zuvor gesicherten Programm- und Datenstand wieder her. + +## Sicherheitsprinzipien + +- Keine direkte Kopie einer geöffneten SQLite-Datei; Backups verwenden die SQLite-Backup-API. +- Jedes Backup enthält ein Manifest mit SHA-256-Prüfsummen und wird sofort geprüft. +- Eine Wiederherstellung erzeugt vorab automatisch ein vollständiges Sicherheitsbackup. +- Update-ZIPs mit `.env`, Datenbank, Uploads oder `node_modules` werden abgelehnt. +- Zugangsdaten aus `.env` werden nur nach ausdrücklicher Auswahl in manuelle Backups aufgenommen. +- Automatische Backups enthalten niemals `.env`. +- Update und Restore besitzen einen lokalen Rollback-Snapshot. + +## Publishing Hardening (1.29.0) + +- persistente Publishing-Queue für API- und Extension-Aufträge +- Schutz vor Doppelveröffentlichungen +- automatische Wiederholungen mit wachsendem Abstand +- Abbruch, manueller Retry und sichere Protokolllöschung +- Fehlerzentrale mit Audit-Ereignissen +- externe IDs, URLs und eBay Offer-IDs +- eBay Statusabgleich, Aktualisierung und Beenden über offizielle API +- Etsy Statusabgleich über offizielle API +- Vinted/Kleinanzeigen als bestätigungspflichtiger Extension-Workflow +- Wiederaufnahme laufender API-Aufträge nach Vendoo-Neustart +- geplante Veröffentlichungen werden an dieselbe gehärtete Queue übergeben + +Vendoo 1.28.0 ergänzt die produktive **Batch Image Factory**. Mehrere Bilder aus der zentralen Galerie können in einem persistenten Auftrag über ein oder mehrere Produktionsprofile für Marktplätze, Webshops und Social Media verarbeitet werden. Ergebnisse bleiben nicht destruktiv, erscheinen wieder in der Galerie und lassen sich gesammelt als ZIP exportieren. + +## Batch Image Factory (1.28.0) + +- Hauptmenü **Bildproduktion** mit Quellenwahl aus der zentralen Bildergalerie +- Acht integrierte Ausgabeprofile sowie eigene speicherbare Profile +- Mehrfachprofile pro Auftrag und bis zu 200 Quellbilder +- Hintergrundentfernung, Sensitivität und Wasserzeichen als globale Auftragsoptionen +- persistente Job- und Positionshistorie mit Pause, Fortsetzen, Abbruch und Retry +- Wiederaufnahme nach Vendoo-Neustart +- nicht destruktive Ergebnisse unter `uploads/batch/` +- automatische Galerieintegration und ZIP-Export pro Auftrag + + +## Neu in 1.12.0 + +Der mobile QR-Upload bietet nun zwei eindeutige Wege: **Kamera öffnen** und **Mediathek auswählen**. Dadurch erzwingt das Smartphone nicht mehr ausschließlich die Kamera. Mehrere vorhandene Bilder können direkt gewählt werden; HEIC/HEIF werden nach Möglichkeit serverseitig zu JPG konvertiert. + +OpenRouter wurde zusätzlich gehärtet: Vendoo erkennt Free-Bildmodelle präziser, prüft Bildinput-Fähigkeit, versucht mehrere kompatible Request-Formen und wechselt bei Fehlern automatisch zum nächsten Modell. Sind aktuell keine kostenlosen Bildmodelle vorhanden, zeigt die Diagnose das ausdrücklich an. + +## Neu in 1.11.0 + +Der QR-Handy-Upload erzeugt jetzt automatisch Links mit der erkannten LAN-IP des Vendoo-PCs. Unter **Einstellungen → Vendoo LAN-Adresse für QR-Upload** kann die Adresse erkannt, geprüft und dauerhaft gespeichert werden. Die mobile Navigation wurde für Smartphone und Tablet komplett stabilisiert. Bei OpenRouter-Bildgenerierung versucht Vendoo automatisch mehrere passende Free-Modelle nacheinander und nutzt im Auto-Modus anschließend OpenAI als Fallback. + +## Neu in 1.10.0 + +Vendoo unterstützt jetzt eine **AI-Job-Queue** für Bildgenerierung. Kleidung kann mit **1 bis 3** Varianten als **Model**, **Ghost Mannequin** oder **Flatlay** generiert werden. Im Listing-Editor gibt es zusätzlich eine **Historie früherer AI-Generierungen**, damit erzeugte Bilder schnell wiederverwendet werden können. + +## Neu in 1.9.0 + +Die Einstellungen wurden um ein eigenes Steuerzentrum für **AI-Model-Fotos** erweitert. Dort lassen sich Provider, Free-Filter, Safety-Blocker, Standard-Preset, Variantenanzahl und das OpenAI-Fallback-Modell pflegen. Zusätzlich kann die UI die aktuell verfügbaren OpenRouter-Bildmodelle direkt laden und anzeigen. + +## Neu in 1.8.0 + +Vendoo kann jetzt neben OpenAI auch OpenRouter für AI-Model-Fotos verwenden. Wenn `ai_model_photo_provider=auto` gesetzt ist und `OPENROUTER_API_KEY` vorhanden ist, fragt Vendoo automatisch aktuelle Bildmodelle von OpenRouter ab, filtert auf Bildausgabe und – sofern aktiviert – nur kostenlose Varianten. Gibt es kein passendes freies Modell mehr, fällt Vendoo sauber auf OpenAI zurück. + +### Neue relevante Settings + +- `ai_model_photo_provider`: `auto`, `openrouter`, `openai` +- `ai_model_photo_openrouter_free_only`: `1` oder `0` +- `ai_model_photo_openrouter_model`: optional feste OpenRouter-Model-ID +- `ai_model_photo_openai_model`: OpenAI-Fallback-Modell, Default `gpt-image-1` + +### Neue ENV-Variablen + +- `OPENROUTER_API_KEY` +- optional `OPENROUTER_SITE_URL` +- optional `OPENROUTER_APP_NAME` + +## Neu in 1.7.0 + +Vendoo kann jetzt für geeignete Kleidungsstücke sichere AI-Model-Fotos erzeugen. Der Workflow prüft zuerst, ob ein Kleidungsartikel ohne Person vorliegt, blockiert sensible Kategorien und erstellt danach 1–3 zusätzliche Lifestyle-/Studio-Bilder mit fiktiven erwachsenen Personen oder alternative Ghost-Mannequin-/Flatlay-Varianten. Die Bilder lassen sich direkt in Generator und Listing-Editor übernehmen. + + +## Neu in 1.26.0 + +- Hauptnavigation mit **Neuer Artikel** direkt unter dem Dashboard und **Artikel** statt Listings +- repariertes Publish Center mit zuverlässigem Smart-Copy-Popup +- zoombare Medienvorschau mit Fit, 100 %, Mausrad und Verschieben +- kompakte lokale SVG-Iconbibliothek für Galerieaktionen +- aktualisierte Verkäufergebühren für eBay, Vinted, Kleinanzeigen und Etsy +- eigenes Extension Center für Chrome, Edge, Firefox und Safari + +# Vendoo + +Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +## Aktueller Entwicklungsstand + +- Version: `1.36.0` +- UI-Slice: `UI 2026 / Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload` +- Startseite: `Studio Flow` +- Tech Stack: Node.js, Express (ESM), Vanilla JS, SQLite (`better-sqlite3`) + +Die vollständige Funktionsbeschreibung steht in [`FEATURES.md`](FEATURES.md). Die freigegebene Designrichtung liegt unter [`MockupDesign/`](MockupDesign/). Der vollständige Listing-Editor ist in [`docs/FULL_LISTING_EDITOR_2026.md`](docs/FULL_LISTING_EDITOR_2026.md) dokumentiert. + +## Lokaler Start + +```bash +npm install +npm start +``` + +Danach ist Vendoo standardmäßig unter `http://localhost:8124` erreichbar. + +## Wichtige Sicherheitsregel + +Nicht versionieren oder weitergeben: + +- `.env` +- `db/vendoo.db*` +- `uploads/` +- Backups und Logs +- API-Schlüssel, OAuth-Tokens und SMTP-Passwörter + +Die bereitgestellte Projektversion enthält ausschließlich `.env.example` und keine produktiven Laufzeitdaten. + +## Browser-Erweiterungen + +Die aktuellen Erweiterungen liegen getrennt unter `extensions/chrome`, `extensions/edge`, `extensions/firefox` und `extensions/safari`. Über den Vendoo-Menüpunkt **Extensions** wird der aktive Browser erkannt, der Installationsordner geöffnet und der Pfad kopiert. Safari benötigt auf macOS zusätzlich Xcode und die mitgelieferte WebExtension-Konvertierung. + +## Mobiler Foto-Upload + +Im Generator und im Listing-Editor kann ein zeitlich begrenzter QR-Code erzeugt werden. Nach dem Scan können Fotos direkt vom Smartphone aufgenommen und ohne Neuladen an Vendoo übertragen werden. Für andere Geräte muss Vendoo über eine erreichbare LAN-IP oder `PUBLIC_BASE_URL` geöffnet sein; `localhost` funktioniert nur auf demselben Gerät. + +## Browser-Erweiterung 1.4.0 + +Die Erweiterungen für Chrome, Edge und Firefox verwenden jetzt eine kompakte, aktionsorientierte Oberfläche. Die primäre Aktion **Nur ausfüllen** trägt ein Listing in das geöffnete Vinted-Formular ein, ohne einen Entwurf zu speichern und ohne eine Veröffentlichung auszulösen. Abschlussaktionen sind davon deutlich getrennt. + + +## Aktueller UI-Stand + +Slice 10 ergänzt das interaktive Dashboard, den aufgeräumten Studio Flow, robuste Publish-Initialisierung, ZIP-Fotodownloads und erweiterte Responsive-Gates. + + +## Branding + +Logo, App-Icons, mobile Größen und Favicons liegen unter `public/brand/`. Die Browser-Erweiterungen für Chrome, Edge und Firefox verwenden dieselbe Icon-Familie. + + +## Local FLUX / ComfyUI +Vendoo can now prefer a local image backend for AI model photos. The intended order is: **Local FLUX / ComfyUI -> OpenRouter Free -> OpenAI**. + +### What was added +- Settings for local URL, token, workflow path and install path +- Backend-only access to the local image server +- PowerShell bootstrap via `setup.ps1` or Settings UI +- Token regeneration for restricted local access + +### Default assumptions +- ComfyUI is reachable at `http://127.0.0.1:8188` +- Workflow file: `local-ai/workflows/vendoo_flux_schnell_api.json` +- Vendoo talks to the local server from the backend, not directly from the browser + +> Note: the included workflow template is a bootstrap starter and may need adjustment to the exact ComfyUI / FLUX node setup on the target machine. + + +## Vollautomatische lokale FLUX-Installation + +Vendoo kann ComfyUI Portable und optional FLUX.1-schnell FP8 direkt aus der Einstellungsseite oder über `setup.bat` installieren. + +### Über die Benutzeroberfläche + +1. **Einstellungen → AI-Model-Fotos** öffnen. +2. Als Provider `Auto` oder `Nur lokales FLUX / ComfyUI` auswählen. +3. Installationsoptionen wählen. +4. **Lokales FLUX installieren** anklicken. +5. Den Fortschritt direkt in Vendoo verfolgen. + +### Über setup.bat + +- `7` – lokales FLUX / ComfyUI installieren oder aktualisieren +- `8` – Status anzeigen +- `9` – lokalen Server starten +- `10` – lokalen Server stoppen + +### Sicherheitsmodell + +ComfyUI wird ausschließlich an `127.0.0.1:8188` gebunden. Der Browser greift nicht direkt auf ComfyUI zu; nur das Vendoo-Backend sendet Bildjobs. Standardmäßig werden Remote-URLs abgelehnt. Ein zufälliger lokaler Token wird zusätzlich in der `.env` gespeichert. Das schützt den Dienst vor Zugriff aus dem LAN, ersetzt aber keine vollständige Prozessisolation gegenüber anderer Software auf demselben Windows-PC. + +### Große Downloads + +Der Modell-Download ist optional und wird vor dem Start ausdrücklich bestätigt. Bereits vorhandene ComfyUI- oder Modelldateien werden nicht ohne `Force` überschrieben. Downloads und Installationsfortschritt werden protokolliert. + + +## FLUX installer status and recovery +The local FLUX installer now records a detailed, resumable state in `local-ai/install-state.json`. The settings page displays individual checks for 7-Zip, the ComfyUI archive, embedded Python, ComfyUI, the FLUX model, workflow and runtime scripts. Failed installations can be started again without blindly discarding complete downloads. + +The PowerShell 5.1 path bug that could turn `C:\Program Files\7-Zip\7z.exe` into the command `C` has been fixed. + + +## FLUX installer status hotfix (1.14.2) +The local FLUX installer now supervises 7-Zip instead of waiting without feedback. During extraction Vendoo shows files, extracted MB, elapsed time, heartbeat and the current 7-Zip log. A stale installer is marked as **stalled** and can be resumed without downloading the complete ComfyUI archive again. + +## FLUX Studio ab 1.21.0 + +Vendoo enthält einen eigenen Hauptmenüpunkt **FLUX Studio**. Dort werden Bilder ausschließlich aus freien Prompts über das lokale ComfyUI/FLUX erzeugt. Einstellbar sind Stil, Ausgabeformat, Seed und Inferenzschritte. Ergebnisse werden in einer lokalen Bibliothek gespeichert, können heruntergeladen, in den Listing-Generator übernommen oder mit dem integrierten Editor nachbearbeitet werden. + +Der frühere FASHN-/Virtual-Try-on-Stack wurde vollständig aufgegeben. Es werden kein Python-, Torch-, ONNX- oder separater VTO-Dienst mehr installiert. Beim ersten Start des aktualisierten Setups werden alte VTO-Runtime, Autostarts und Einstellungen entfernt; eigene frühere Modelbilder werden zur Sicherheit nach `uploads/ai-generated/legacy-models` verschoben. + +### Update +1. Vendoo schließen. +2. Paketinhalt über die bestehende Installation kopieren und Dateien ersetzen. +3. `setup.bat` starten und **Reparieren** ausführen. +4. Vendoo starten und den Menüpunkt **FLUX Studio** öffnen. +## FLUX Studio Pro ab 1.22.0 + +Slice 30 erweitert das reine Prompt-Studio zu einem persistenten Job-Center. Aufträge können 1, 2 oder 4 Varianten mit eigenen Seeds erzeugen. Seed Lock, Wiederholen, „Ähnlich erneut erzeugen“, Favoriten, Queue-/History-Anbindung, System-/GPU-Daten und Wiederaufnahme nach Vendoo-Neustart sind integriert. + +Die Fortschrittsanzeige verwendet im FLUX-Studio-Pro-Pfad ausschließlich reale Phasen und verstrichene Laufzeit. Es werden keine Prozentwerte aus einer angenommenen Renderdauer errechnet. + +### Update auf 1.22.0 + +1. Vendoo und ComfyUI-Fenster schließen. +2. Den Inhalt des Paket-Unterordners `vendoo` über die bestehende Installation kopieren und Dateien ersetzen. +3. Den bestehenden Zielordner und Laufzeitdaten nicht löschen. +4. `setup.bat` starten und **3 – Update** ausführen. +5. Bei Bedarf **9 – Lokales FLUX / ComfyUI starten**, danach **8 – Status** prüfen. +6. Vendoo starten und FLUX Studio öffnen. + + + +## Artikel Quality Center + +Vendoo 1.28.0 prüft Artikel vor dem Publishing auf Blocker, Vollständigkeit, Bildqualität, mögliche Duplikate und Plattformtauglichkeit. AI-Verbesserungen werden nur als Vorschlag angezeigt und erst nach ausdrücklicher Bestätigung gespeichert. + +## Installationsziel wählen (1.36.0) + +`setup.bat` führt auf Windows durch lokale Node-Installation oder Docker Desktop. Für Linux, NAS, Portainer und VPS steht `setup.sh` bereit. Beide Wege verwenden dieselbe `.env`, dieselben Compose-Dateien und dieselben persistenten Datenbereiche. + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/SECURITY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/SECURITY.md new file mode 100644 index 0000000..c00fc0f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/SECURITY.md @@ -0,0 +1,81 @@ +# Sicherheit + +## Vertrauliche Dateien + +Folgende Dateien dürfen niemals in Git oder öffentliche Pakete gelangen: + +- `.env` +- `db/*.db`, `db/*.db-wal`, `db/*.db-shm` +- `uploads/` +- Backups, Logs und Session-Daten +- private Schlüssel und Zertifikate + +## Online-Betrieb + +Vendoo darf nicht durch eine direkte Portfreigabe von Port 8124 veröffentlicht werden. Für öffentliche Nutzer ist eine getrennte Server-/VPS-Instanz hinter HTTPS und Reverse Proxy vorgesehen. + +## Meldung von Sicherheitsproblemen + +Sicherheitsprobleme nicht mit Zugangsdaten oder personenbezogenen Daten in öffentliche Issues schreiben. + + +## Lokaler FLUX / ComfyUI + +- ComfyUI wird standardmäßig nur an `127.0.0.1` gebunden. +- Vendoo blockiert Remote-ComfyUI-URLs, solange `LOCAL_IMAGE_ALLOW_REMOTE` nicht ausdrücklich aktiviert wird. +- Die ComfyUI-Oberfläche wird nicht über das LAN veröffentlicht. +- API-Aufrufe erfolgen serverseitig durch Vendoo. +- Ein zufälliger Token wird für die lokale Integrationskonfiguration erzeugt. +- Andere Prozesse auf demselben Windows-Rechner können theoretisch ebenfalls auf einen localhost-Dienst zugreifen; für echte Prozessisolation wäre ein separates Windows-Konto oder eine VM erforderlich. + +## FLUX Studio + +- ComfyUI bleibt standardmäßig ausschließlich an `127.0.0.1` gebunden. +- Das FLUX Studio akzeptiert nur Textprompts und erzeugt Dateien unter `uploads/ai-generated`. +- API-Pfade für Bibliothek und Bearbeitungen werden serverseitig auf dieses Verzeichnis begrenzt. +- Start und Stopp der lokalen Engine erfordern eine Admin-Rolle. +- Bearbeitungen erzeugen neue Dateien; Originalausgaben werden nicht still überschrieben. + + +## Operations Center, Backups und Updates + +- Backups werden mit einem Manifest und SHA-256-Prüfsummen erzeugt und vor der Freigabe verifiziert. +- Sicherungen, die `.env` enthalten, enthalten möglicherweise API-Schlüssel, Tokens und Zugangsdaten. Sie sind wie Passwörter zu behandeln und dürfen nicht unverschlüsselt weitergegeben werden. +- Restore- und Update-Archive werden vor der Anwendung in einem lokalen Staging-Bereich geprüft. +- Archive mit Pfad-Traversal, absoluten Pfaden oder unzulässigen Laufzeitdaten werden abgewiesen. +- Updates dürfen keine `.env`, produktive Datenbank, Uploads, Backups, Logs, Modelle oder `node_modules` mitbringen. +- Das eigentliche Anwenden erfolgt nur bei gestopptem Vendoo über `setup.bat` und erzeugt vorher einen Rollback-Snapshot. +- Eine optionale Update-Manifest-URL sollte ausschließlich über HTTPS und aus einer kontrollierten Quelle verwendet werden. +- Extension-Heartbeats enthalten nur Diagnosemetadaten wie Browser, Version, Plattform und Zeitpunkt; keine Formularinhalte oder Zugangsdaten. +## Mehrbenutzer- und Server-Sicherheit ab 1.31.0 + +- Vendoo bindet standardmäßig an `127.0.0.1`. LAN-Zugriff muss ausdrücklich über `VENDOO_BIND_HOST=0.0.0.0` aktiviert werden. +- Öffentlicher Betrieb ausschließlich über HTTPS und einen korrekt konfigurierten Reverse Proxy. `VENDOO_TRUST_PROXY=true` nur setzen, wenn der Proxy vertrauenswürdig ist und Client-IP-Header kontrolliert. +- `VENDOO_ALLOWED_HOSTS` und `VENDOO_ALLOWED_ORIGINS` eng begrenzen. +- Rollen ersetzen keine Betriebssystemrechte: Datenbank, `.env`, Backups und Uploads müssen auf Dateisystemebene geschützt bleiben. +- Bearbeitungssperren verhindern parallele Änderungen, ersetzen aber keine Backups. +- Audit-Logs können personenbezogene Betriebsdaten wie Benutzer, IP und Aktionen enthalten; Aufbewahrung und Zugriff müssen organisatorisch geregelt werden. + + + +## Architektur-Sicherheitsregeln ab 1.35.0 + +- Neue Kernel-Routen sind Deny-by-default und benötigen eine registrierte Policy. +- Modulmanifeste, Abhängigkeiten, Capabilities und Ownership werden vor dem Start validiert. +- Zyklische Abhängigkeiten, fehlende Capabilities und doppelte Ressourcen-Ownership blockieren den Kernelstart. +- Drittanbieter-Erweiterungen dürfen nicht im Vendoo-Hauptprozess ausgeführt werden. +- Themes dürfen nur freigegebene, typisierte Design Tokens verändern; freies CSS, JavaScript und externe URLs sind verboten. +- Der Event Bus ordnet Listener einem Owner zu, damit Module beim Stoppen ihre Listener vollständig entfernen können. +- Das Architektur-Gate `npm run verify:architecture` ist verpflichtender Bestandteil von CI und Releases. + +## Theme-Sicherheit ab 1.36.0 + +- Theme-Profile akzeptieren ausschließlich bekannte, typisierte Design Tokens. +- Freies CSS, JavaScript, HTML, URLs und Dateipfade sind nicht zulässig. +- Alle Werte werden im Browser zur Benutzerführung und erneut auf dem Server validiert. +- Normale Benutzer können nur die eigene Theme-Präferenz verändern. +- Theme-Profile und Systemstandard erfordern `settings.manage`. +- Theme-Änderungen werden auditiert. +- Importierte Profile durchlaufen dieselbe Validierung wie manuell erstellte Profile. +- Kontrastprüfungen verhindern nicht automatisch jede bewusste administrative Gestaltung, zeigen problematische Paare aber vor dem Speichern deutlich an. + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/THIRD_PARTY_NOTICES.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..8219ed4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Drittanbieter-Hinweise für lokale AI-Engines + +## FLUX / ComfyUI + +Vendoo kann eine lokale ComfyUI-Installation mit einem FLUX-kompatiblen Modell ansteuern. ComfyUI, Modelle, Custom Nodes und deren Gewichte werden nicht als Vendoo-eigene Software ausgegeben. Für jede installierte Komponente gelten die jeweiligen Lizenz- und Nutzungsbedingungen der Herausgeber. + +Vendoo liefert keine FASHN-VTON-, Python-, Torch-, ONNX- oder Human-Parser-Komponenten mehr aus und installiert diese auch nicht mehr. + + +## Lucide Icons + +Die lokale Vendoo-Iconbibliothek verwendet eine kompakte Auswahl von Lucide-Icon-Geometrien. +Lucide steht unter der ISC-Lizenz. Projekt: https://lucide.dev/ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/architecture-boundaries.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/architecture-boundaries.json new file mode 100644 index 0000000..0c928a5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/architecture-boundaries.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "architecture": "modular-monolith", + "rules": { + "modulePrefix": "vendoo.", + "denyImplicitRoutes": true, + "denyCrossModuleDatabaseAccess": true, + "denyCrossModuleInternalImports": true, + "thirdPartyExtensionsInMainProcess": false, + "themeCustomCss": false, + "themeTokensOnly": true + }, + "migration": { + "mode": "strangler", + "legacyBridgeStatus": "legacy-bridge", + "frameworkMigration": false, + "databaseReplacement": false + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/contracts/module.schema.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/contracts/module.schema.json new file mode 100644 index 0000000..737ac22 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/contracts/module.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vendoo.local/contracts/module.schema.json", + "title": "Vendoo Module Manifest", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"], + "properties": { + "schemaVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, + "type": { "enum": ["core", "feature", "adapter", "extension-host"] }, + "status": { "enum": ["native", "legacy-bridge", "disabled"] }, + "description": { "type": "string", "maxLength": 500 }, + "requires": { "$ref": "#/$defs/moduleIds" }, + "permissions": { "$ref": "#/$defs/capabilities" }, + "provides": { "$ref": "#/$defs/capabilities" }, + "consumes": { "$ref": "#/$defs/capabilities" }, + "owns": { "$ref": "#/$defs/capabilities" } + }, + "$defs": { + "moduleIds": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" } + }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" } + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/core/themes/theme-contract.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/core/themes/theme-contract.mjs new file mode 100644 index 0000000..24e1e66 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/core/themes/theme-contract.mjs @@ -0,0 +1,169 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { APP_ROOT } from '../../../lib/runtime-paths.mjs'; +import { PlatformError } from '../../kernel/errors.mjs'; + +const source = JSON.parse(readFileSync(join(APP_ROOT, 'public', 'design-system', 'tokens', 'source.json'), 'utf8')); +const BUILTIN_THEME_IDS = new Set([...source.themes, 'system', 'inherit']); +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +const RGB_COLOR = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i; +const DIMENSION = /^-?\d+(?:\.\d+)?px$/i; +const THEME_ID = /^[a-z][a-z0-9-]{2,39}$/; + +function expandHex(value) { + const hex = value.replace('#', ''); + if (hex.length === 3 || hex.length === 4) return hex.split('').map(char => char + char).join(''); + return hex; +} + +function parseColor(value) { + const raw = String(value || '').trim(); + if (HEX_COLOR.test(raw)) { + const hex = expandHex(raw); + return [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16)); + } + const match = raw.match(RGB_COLOR); + if (!match) return null; + const channels = match.slice(1, 4).map(Number); + return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels : null; +} + +function validColor(value) { + if (!parseColor(value)) return false; + const match = String(value).trim().match(RGB_COLOR); + if (!match) return true; + const alpha = match[4] === undefined ? 1 : Number(match[4]); + return Number.isFinite(alpha) && alpha >= 0 && alpha <= 1; +} + +function validateValue(token, value) { + const raw = String(value ?? '').trim(); + if (token.type === 'color') return validColor(raw); + if (token.type === 'dimension') { + if (!DIMENSION.test(raw)) return false; + const numeric = Number.parseFloat(raw); + if (token.min !== undefined && numeric < Number(token.min)) return false; + if (token.max !== undefined && numeric > Number(token.max)) return false; + return true; + } + return false; +} + +function relativeLuminance(value) { + const rgb = parseColor(value); + if (!rgb) return null; + const linear = rgb.map(channel => { + const normalized = channel / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +export function contrastRatio(foreground, background) { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + if (first === null || second === null) return null; + const lighter = Math.max(first, second); + const darker = Math.min(first, second); + return (lighter + 0.05) / (darker + 0.05); +} + +export function getBaseThemeValues(themeId = 'light') { + const base = source.themes.includes(themeId) ? themeId : 'light'; + return Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, token[base]])); +} + +export function resolveThemeValues({ baseTheme = 'light', values = {} } = {}) { + return Object.freeze({ ...getBaseThemeValues(baseTheme), ...values }); +} + +export function evaluateThemeAccessibility({ baseTheme = 'light', values = {} } = {}) { + const resolved = resolveThemeValues({ baseTheme, values }); + const checks = [ + ['primary-on-canvas', 'color.ink', 'color.canvas', 4.5], + ['primary-on-surface', 'color.ink', 'color.surface', 4.5], + ['secondary-on-surface', 'color.inkSecondary', 'color.surface', 4.5], + ['muted-on-surface', 'color.inkMuted', 'color.surface', 3], + ['brand-on-surface', 'color.brand', 'color.surface', 3], + ['danger-on-surface', 'color.danger', 'color.surface', 3], + ['line-on-surface', 'color.lineStrong', 'color.surface', 3], + ].map(([id, foregroundToken, backgroundToken, minimum]) => { + const ratio = contrastRatio(resolved[foregroundToken], resolved[backgroundToken]); + return { + id, + foregroundToken, + backgroundToken, + ratio: ratio === null ? null : Number(ratio.toFixed(2)), + minimum, + ok: ratio !== null && ratio >= minimum, + }; + }); + return Object.freeze({ + ok: checks.every(check => check.ok), + checks: Object.freeze(checks), + failures: Object.freeze(checks.filter(check => !check.ok)), + }); +} + +export function getThemeContract() { + return { + schemaVersion: source.schemaVersion, + contractVersion: source.contractVersion, + themes: [...source.themes], + tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, { + css: token.css, + type: token.type, + customizable: Boolean(token.customizable), + label: token.label || name, + group: token.group || name.split('.')[0], + ...(token.min !== undefined ? { min: token.min } : {}), + ...(token.max !== undefined ? { max: token.max } : {}), + }])), + }; +} + +export function getBuiltinThemes() { + return source.themes.map(id => ({ + id, + name: ({ light: 'Hell', dark: 'Dunkel', contrast: 'Hoher Kontrast' })[id] || id, + base_theme: id, + builtin: true, + values: {}, + resolved_values: getBaseThemeValues(id), + accessibility: evaluateThemeAccessibility({ baseTheme: id }), + })); +} + +export function validateThemeDefinition(theme, { allowExistingId = false } = {}) { + if (!theme || typeof theme !== 'object' || Array.isArray(theme)) { + throw new PlatformError('Theme-Definition ist ungültig.', { code: 'THEME_INVALID', status: 400, expose: true }); + } + const id = String(theme.id || '').trim().toLowerCase(); + const name = String(theme.name || '').trim(); + const baseTheme = String(theme.base_theme || theme.baseTheme || 'light').trim().toLowerCase(); + if (!THEME_ID.test(id) || BUILTIN_THEME_IDS.has(id)) throw new PlatformError('Theme-ID ist ungültig oder reserviert.', { code: 'THEME_ID_INVALID', status: 400, expose: true }); + if (!allowExistingId && !id.startsWith('custom-')) throw new PlatformError('Eigene Theme-IDs müssen mit custom- beginnen.', { code: 'THEME_ID_PREFIX', status: 400, expose: true }); + if (!name || name.length > 80) throw new PlatformError('Theme-Name ist ungültig.', { code: 'THEME_NAME_INVALID', status: 400, expose: true }); + if (!source.themes.includes(baseTheme)) throw new PlatformError('Basis-Theme ist ungültig.', { code: 'THEME_BASE_INVALID', status: 400, expose: true }); + const values = {}; + for (const [tokenName, value] of Object.entries(theme.values || {})) { + const token = source.tokens[tokenName]; + if (!token || !token.customizable) throw new PlatformError(`Token ist nicht anpassbar: ${tokenName}`, { code: 'THEME_TOKEN_FORBIDDEN', status: 400, expose: true }); + if (!validateValue(token, value)) throw new PlatformError(`Ungültiger Wert für ${tokenName}.`, { code: 'THEME_VALUE_INVALID', status: 400, expose: true }); + values[tokenName] = String(value).trim(); + } + const accessibility = evaluateThemeAccessibility({ baseTheme, values }); + return Object.freeze({ schemaVersion: 1, id, name, base_theme: baseTheme, values: Object.freeze(values), accessibility }); +} + +export function validateThemePreference(input, { customThemeExists = () => false } = {}) { + const themeId = String(input?.theme_id || input?.themeId || 'inherit').trim().toLowerCase(); + const density = String(input?.density || 'comfortable').trim().toLowerCase(); + const reducedMotion = String(input?.reduced_motion || input?.reducedMotion || 'system').trim().toLowerCase(); + if (![...BUILTIN_THEME_IDS].includes(themeId) && !customThemeExists(themeId)) { + throw new PlatformError('Gewähltes Theme existiert nicht.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + } + if (!['compact', 'comfortable', 'spacious'].includes(density)) throw new PlatformError('UI-Dichte ist ungültig.', { code: 'THEME_DENSITY_INVALID', status: 400, expose: true }); + if (!['system', 'reduce', 'allow'].includes(reducedMotion)) throw new PlatformError('Bewegungseinstellung ist ungültig.', { code: 'THEME_MOTION_INVALID', status: 400, expose: true }); + return Object.freeze({ theme_id: themeId, density, reduced_motion: reducedMotion }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/errors.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/errors.mjs new file mode 100644 index 0000000..4c008d7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/errors.mjs @@ -0,0 +1,31 @@ +export class PlatformError extends Error { + constructor(message, { code = 'PLATFORM_ERROR', status = 500, details = null, expose = false, cause = undefined } = {}) { + super(String(message || 'Unbekannter Plattformfehler'), { cause }); + this.name = 'PlatformError'; + this.code = String(code || 'PLATFORM_ERROR'); + this.status = Number.isInteger(status) ? status : 500; + this.details = details; + this.expose = Boolean(expose || this.status < 500); + } +} + +export function normalizePlatformError(error) { + if (error instanceof PlatformError) return error; + return new PlatformError(error?.message || 'Interner Serverfehler', { + code: error?.code || 'INTERNAL_ERROR', + status: Number.isInteger(error?.status) ? error.status : 500, + details: error?.details || null, + expose: Boolean(error?.expose), + cause: error, + }); +} + +export function platformErrorPayload(error, requestId = null) { + const normalized = normalizePlatformError(error); + return { + error: normalized.expose ? normalized.message : 'Interner Serverfehler', + code: normalized.code, + request_id: requestId || null, + ...(normalized.expose && normalized.details ? { details: normalized.details } : {}), + }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/event-bus.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/event-bus.mjs new file mode 100644 index 0000000..07964a1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/event-bus.mjs @@ -0,0 +1,77 @@ +import { PlatformError } from './errors.mjs'; + +const EVENT_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class EventBus { + #listeners = new Map(); + #maxListeners; + + constructor({ maxListenersPerEvent = 50 } = {}) { + this.#maxListeners = Math.max(1, Number(maxListenersPerEvent) || 50); + } + + on(eventName, handler, { owner = 'core', once = false } = {}) { + if (!EVENT_PATTERN.test(String(eventName))) { + throw new PlatformError(`Ungültiger Eventname: ${eventName}`, { code: 'EVENT_NAME_INVALID', status: 500 }); + } + if (typeof handler !== 'function') { + throw new PlatformError('Event-Handler muss eine Funktion sein.', { code: 'EVENT_HANDLER_INVALID', status: 500 }); + } + const listeners = this.#listeners.get(eventName) || []; + if (listeners.length >= this.#maxListeners) { + throw new PlatformError(`Zu viele Listener für ${eventName}.`, { code: 'EVENT_LISTENER_LIMIT', status: 500 }); + } + const entry = Object.freeze({ handler, owner: String(owner || 'core'), once: Boolean(once) }); + listeners.push(entry); + this.#listeners.set(eventName, listeners); + return () => this.off(eventName, handler); + } + + once(eventName, handler, options = {}) { + return this.on(eventName, handler, { ...options, once: true }); + } + + off(eventName, handler) { + const listeners = this.#listeners.get(eventName) || []; + const remaining = listeners.filter(entry => entry.handler !== handler); + if (remaining.length) this.#listeners.set(eventName, remaining); + else this.#listeners.delete(eventName); + return remaining.length !== listeners.length; + } + + removeOwner(owner) { + let removed = 0; + for (const [eventName, listeners] of this.#listeners) { + const remaining = listeners.filter(entry => { + const match = entry.owner === owner; + if (match) removed += 1; + return !match; + }); + if (remaining.length) this.#listeners.set(eventName, remaining); + else this.#listeners.delete(eventName); + } + return removed; + } + + async emit(eventName, payload = null, context = {}) { + const listeners = [...(this.#listeners.get(eventName) || [])]; + const results = []; + for (const entry of listeners) { + try { + results.push({ owner: entry.owner, ok: true, value: await entry.handler(payload, Object.freeze({ ...context, eventName })) }); + } catch (error) { + results.push({ owner: entry.owner, ok: false, error }); + } finally { + if (entry.once) this.off(eventName, entry.handler); + } + } + return results; + } + + snapshot() { + return [...this.#listeners.entries()].map(([event, listeners]) => ({ + event, + listeners: listeners.map(entry => ({ owner: entry.owner, once: entry.once })), + })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/feature-flags.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/feature-flags.mjs new file mode 100644 index 0000000..a0ac014 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/feature-flags.mjs @@ -0,0 +1,59 @@ +import { PlatformError } from './errors.mjs'; + +const FLAG_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +function envKey(flag) { + return `VENDOO_FEATURE_${flag.replace(/[^a-z0-9]/gi, '_').toUpperCase()}`; +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + throw new PlatformError(`Ungültiger Feature-Flag-Wert: ${value}`, { code: 'FEATURE_FLAG_VALUE_INVALID', status: 500 }); +} + +export class FeatureFlagRegistry { + #flags = new Map(); + + define(name, { defaultValue = false, owner = 'vendoo.system', description = '', mutable = false } = {}) { + const normalized = String(name || ''); + if (!FLAG_PATTERN.test(normalized)) { + throw new PlatformError(`Ungültiger Feature-Flag: ${normalized}`, { code: 'FEATURE_FLAG_NAME_INVALID', status: 500 }); + } + if (this.#flags.has(normalized)) { + throw new PlatformError(`Feature-Flag bereits definiert: ${normalized}`, { code: 'FEATURE_FLAG_DUPLICATE', status: 500 }); + } + const configured = parseBoolean(process.env[envKey(normalized)], Boolean(defaultValue)); + this.#flags.set(normalized, { + name: normalized, + value: configured, + defaultValue: Boolean(defaultValue), + owner: String(owner), + description: String(description), + mutable: Boolean(mutable), + source: process.env[envKey(normalized)] === undefined ? 'default' : 'environment', + }); + return configured; + } + + isEnabled(name) { + if (!this.#flags.has(name)) { + throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 500 }); + } + return this.#flags.get(name).value; + } + + set(name, value) { + const flag = this.#flags.get(name); + if (!flag) throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 404, expose: true }); + if (!flag.mutable) throw new PlatformError(`Feature-Flag ist nicht zur Laufzeit änderbar: ${name}`, { code: 'FEATURE_FLAG_IMMUTABLE', status: 409, expose: true }); + flag.value = Boolean(value); + flag.source = 'runtime'; + return flag.value; + } + + list() { + return [...this.#flags.values()].map(flag => ({ ...flag })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/lifecycle.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/lifecycle.mjs new file mode 100644 index 0000000..85608e8 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/lifecycle.mjs @@ -0,0 +1,49 @@ +import { PlatformError } from './errors.mjs'; + +export class LifecycleManager { + #state = 'created'; + #hooks = []; + + register({ id, order = 100, start = async () => {}, stop = async () => {} }) { + if (!id || this.#hooks.some(hook => hook.id === id)) { + throw new PlatformError(`Ungültiger oder doppelter Lifecycle-Hook: ${id}`, { code: 'LIFECYCLE_HOOK_INVALID' }); + } + this.#hooks.push({ id: String(id), order: Number(order) || 100, start, stop, state: 'registered' }); + } + + async start(context) { + if (!['created', 'stopped'].includes(this.#state)) throw new PlatformError(`Kernel kann aus Zustand ${this.#state} nicht starten.`, { code: 'LIFECYCLE_STATE_INVALID' }); + this.#state = 'starting'; + const started = []; + try { + for (const hook of [...this.#hooks].sort((a, b) => a.order - b.order)) { + hook.state = 'starting'; + await hook.start(context); + hook.state = 'running'; + started.push(hook); + } + this.#state = 'running'; + } catch (error) { + for (const hook of started.reverse()) { + try { await hook.stop({ ...context, rollback: true }); } catch {} + hook.state = 'stopped'; + } + this.#state = 'failed'; + throw error; + } + } + + async stop(context) { + if (!['running', 'starting'].includes(this.#state)) return; + this.#state = 'stopping'; + for (const hook of [...this.#hooks].sort((a, b) => b.order - a.order)) { + if (hook.state !== 'running') continue; + try { await hook.stop(context); } finally { hook.state = 'stopped'; } + } + this.#state = 'stopped'; + } + + snapshot() { + return { state: this.#state, hooks: this.#hooks.map(({ start: _s, stop: _t, ...hook }) => ({ ...hook })) }; + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-contract.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-contract.mjs new file mode 100644 index 0000000..1b25b7f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-contract.mjs @@ -0,0 +1,47 @@ +import { PlatformError } from './errors.mjs'; + +const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/; +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const CAPABILITY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; +const TYPES = new Set(['core', 'feature', 'adapter', 'extension-host']); +const STATUSES = new Set(['native', 'legacy-bridge', 'disabled']); + +function uniqueStrings(values, label, pattern = CAPABILITY) { + if (!Array.isArray(values)) throw new PlatformError(`${label} muss ein Array sein.`, { code: 'MODULE_MANIFEST_INVALID' }); + const normalized = values.map(value => String(value || '').trim()); + if (normalized.some(value => !pattern.test(value))) { + throw new PlatformError(`${label} enthält einen ungültigen Eintrag.`, { code: 'MODULE_MANIFEST_INVALID' }); + } + if (new Set(normalized).size !== normalized.length) { + throw new PlatformError(`${label} enthält Duplikate.`, { code: 'MODULE_MANIFEST_INVALID' }); + } + return normalized; +} + +export function validateModuleManifest(manifest) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new PlatformError('Modulmanifest fehlt oder ist ungültig.', { code: 'MODULE_MANIFEST_INVALID' }); + } + const normalized = { + schemaVersion: Number(manifest.schemaVersion || 1), + id: String(manifest.id || '').trim(), + name: String(manifest.name || '').trim(), + version: String(manifest.version || '').trim(), + type: String(manifest.type || '').trim(), + status: String(manifest.status || 'native').trim(), + description: String(manifest.description || '').trim(), + requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)), + permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')), + provides: Object.freeze(uniqueStrings(manifest.provides || [], 'provides')), + consumes: Object.freeze(uniqueStrings(manifest.consumes || [], 'consumes')), + owns: Object.freeze(uniqueStrings(manifest.owns || [], 'owns')), + }; + if (normalized.schemaVersion !== 1) throw new PlatformError('Nicht unterstützte Manifestversion.', { code: 'MODULE_MANIFEST_VERSION' }); + if (!MODULE_ID.test(normalized.id)) throw new PlatformError(`Ungültige Modul-ID: ${normalized.id}`, { code: 'MODULE_ID_INVALID' }); + if (!normalized.name) throw new PlatformError(`Modulname fehlt: ${normalized.id}`, { code: 'MODULE_NAME_REQUIRED' }); + if (!SEMVER.test(normalized.version)) throw new PlatformError(`Ungültige Modulversion: ${normalized.id}`, { code: 'MODULE_VERSION_INVALID' }); + if (!TYPES.has(normalized.type)) throw new PlatformError(`Ungültiger Modultyp: ${normalized.id}`, { code: 'MODULE_TYPE_INVALID' }); + if (!STATUSES.has(normalized.status)) throw new PlatformError(`Ungültiger Modulstatus: ${normalized.id}`, { code: 'MODULE_STATUS_INVALID' }); + if (normalized.requires.includes(normalized.id)) throw new PlatformError(`Modul darf sich nicht selbst benötigen: ${normalized.id}`, { code: 'MODULE_SELF_DEPENDENCY' }); + return Object.freeze(normalized); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-registry.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-registry.mjs new file mode 100644 index 0000000..5d162d5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/module-registry.mjs @@ -0,0 +1,128 @@ +import { PlatformError } from './errors.mjs'; +import { validateModuleManifest } from './module-contract.mjs'; + +export class ModuleRegistry { + #modules = new Map(); + #startOrder = []; + #ownership = new Map(); + + register({ manifest, lifecycle = {} }) { + const validated = validateModuleManifest(manifest); + if (this.#modules.has(validated.id)) { + throw new PlatformError(`Modul bereits registriert: ${validated.id}`, { code: 'MODULE_DUPLICATE' }); + } + for (const resource of validated.owns) { + const owner = this.#ownership.get(resource); + if (owner) throw new PlatformError(`Ressource ${resource} wird bereits von ${owner} besessen.`, { code: 'MODULE_OWNERSHIP_CONFLICT' }); + } + const record = { + manifest: validated, + lifecycle: { + start: typeof lifecycle.start === 'function' ? lifecycle.start : async () => {}, + stop: typeof lifecycle.stop === 'function' ? lifecycle.stop : async () => {}, + health: typeof lifecycle.health === 'function' ? lifecycle.health : async () => ({ ok: true }), + }, + state: validated.status === 'disabled' ? 'disabled' : 'registered', + startedAt: null, + error: null, + }; + this.#modules.set(validated.id, record); + for (const resource of validated.owns) this.#ownership.set(resource, validated.id); + return validated; + } + + get(id) { + return this.#modules.get(id) || null; + } + + #resolveStartOrder() { + const visiting = new Set(); + const visited = new Set(); + const order = []; + const visit = id => { + if (visited.has(id)) return; + if (visiting.has(id)) throw new PlatformError(`Zyklische Modulabhängigkeit bei ${id}.`, { code: 'MODULE_DEPENDENCY_CYCLE' }); + const record = this.#modules.get(id); + if (!record) throw new PlatformError(`Fehlende Modulabhängigkeit: ${id}`, { code: 'MODULE_DEPENDENCY_MISSING' }); + visiting.add(id); + for (const dependency of record.manifest.requires) visit(dependency); + visiting.delete(id); + visited.add(id); + order.push(id); + }; + for (const id of this.#modules.keys()) visit(id); + const capabilities = new Set([...this.#modules.values()].flatMap(record => record.manifest.provides)); + for (const record of this.#modules.values()) { + for (const capability of record.manifest.consumes) { + if (!capabilities.has(capability)) throw new PlatformError(`Fehlende Capability ${capability} für ${record.manifest.id}.`, { code: 'MODULE_CAPABILITY_MISSING' }); + } + } + return order; + } + + async startAll(context) { + this.#startOrder = this.#resolveStartOrder(); + const started = []; + for (const id of this.#startOrder) { + const record = this.#modules.get(id); + if (record.state === 'disabled') continue; + try { + record.state = 'starting'; + await record.lifecycle.start(Object.freeze({ ...context, module: record.manifest })); + record.state = 'running'; + record.startedAt = new Date().toISOString(); + started.push(id); + } catch (error) { + record.state = 'failed'; + record.error = error?.message || String(error); + for (const startedId of started.reverse()) { + const startedRecord = this.#modules.get(startedId); + try { await startedRecord.lifecycle.stop(Object.freeze({ ...context, module: startedRecord.manifest, rollback: true })); } catch {} + startedRecord.state = 'stopped'; + } + throw new PlatformError(`Modulstart fehlgeschlagen: ${id}`, { code: 'MODULE_START_FAILED', cause: error, details: { module: id } }); + } + } + } + + async stopAll(context) { + for (const id of [...this.#startOrder].reverse()) { + const record = this.#modules.get(id); + if (!record || record.state !== 'running') continue; + try { + record.state = 'stopping'; + await record.lifecycle.stop(Object.freeze({ ...context, module: record.manifest })); + record.state = 'stopped'; + } catch (error) { + record.state = 'failed'; + record.error = error?.message || String(error); + } + } + } + + async health() { + const checks = []; + for (const [id, record] of this.#modules) { + if (record.state === 'disabled') { + checks.push({ id, ok: true, state: 'disabled' }); + continue; + } + try { + const result = await record.lifecycle.health({ module: record.manifest }); + checks.push({ id, state: record.state, ok: record.state === 'running' && result?.ok !== false, ...(result || {}) }); + } catch (error) { + checks.push({ id, state: record.state, ok: false, error: error?.message || String(error) }); + } + } + return checks; + } + + snapshot() { + return [...this.#modules.values()].map(record => ({ + ...record.manifest, + state: record.state, + startedAt: record.startedAt, + error: record.error, + })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/platform-kernel.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/platform-kernel.mjs new file mode 100644 index 0000000..a2db3e2 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/platform-kernel.mjs @@ -0,0 +1,82 @@ +import { EventBus } from './event-bus.mjs'; +import { FeatureFlagRegistry } from './feature-flags.mjs'; +import { LifecycleManager } from './lifecycle.mjs'; +import { ModuleRegistry } from './module-registry.mjs'; +import { PolicyEngine } from './policy-engine.mjs'; +import { RouteRegistry } from './route-registry.mjs'; +import { loadBuiltInModules } from '../modules/catalog.mjs'; + +export function createPlatformKernel({ version, hasPermission }) { + const events = new EventBus(); + const features = new FeatureFlagRegistry(); + const modules = new ModuleRegistry(); + const policies = new PolicyEngine(); + const routes = new RouteRegistry({ policyEngine: policies }); + const lifecycle = new LifecycleManager(); + + policies.register('platform.authenticated', ({ user }) => Boolean(user), { + description: 'Erfordert eine gültige Vendoo-Sitzung.', + }); + policies.register('platform.security-admin', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), { + description: 'Erfordert die Berechtigung security.manage.', + }); + policies.register('themes.use', ({ user }) => Boolean(user), { + owner: 'vendoo.themes', description: 'Erfordert eine gültige Sitzung für persönliche Darstellungseinstellungen.', + }); + policies.register('themes.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), { + owner: 'vendoo.themes', description: 'Erfordert settings.manage für systemweite und eigene Theme-Profile.', + }); + + features.define('platform.module-kernel', { defaultValue: true, description: 'Aktiviert den modularen Plattformkernel.' }); + features.define('platform.route-contracts', { defaultValue: true, description: 'Aktiviert registrierte Routenverträge.' }); + features.define('themes.token-runtime', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den versionierten Design-Token-Vertrag.' }); + features.define('themes.manager', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den sicheren Theme Manager mit Benutzerpräferenzen.' }); + features.define('themes.accessibility-gate', { defaultValue: true, owner: 'vendoo.themes', description: 'Prüft Theme-Kontraste und sichere Tokenwerte.' }); + features.define('extensions.isolated-host', { defaultValue: false, owner: 'vendoo.security', description: 'Reserviert für einen späteren isolierten Extension-Host.' }); + + for (const module of loadBuiltInModules()) modules.register(module); + lifecycle.register({ + id: 'modules', + order: 20, + start: context => modules.startAll(context), + stop: context => modules.stopAll(context), + }); + + const context = Object.freeze({ version, events, features, policies }); + return Object.freeze({ + version, + events, + features, + lifecycle, + modules, + policies, + routes, + async start() { + await lifecycle.start(context); + await events.emit('platform.started', { version }, { owner: 'vendoo.system' }); + }, + async stop() { + await events.emit('platform.stopping', { version }, { owner: 'vendoo.system' }); + await lifecycle.stop(context); + }, + async health() { + const moduleChecks = await modules.health(); + return { + ok: lifecycle.snapshot().state === 'running' && moduleChecks.every(check => check.ok), + lifecycle: lifecycle.snapshot(), + modules: moduleChecks, + }; + }, + snapshot() { + return { + version, + architecture: 'modular-monolith', + lifecycle: lifecycle.snapshot(), + modules: modules.snapshot(), + features: features.list(), + policies: policies.list(), + routes: routes.list(), + }; + }, + }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/policy-engine.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/policy-engine.mjs new file mode 100644 index 0000000..72613be --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/policy-engine.mjs @@ -0,0 +1,30 @@ +import { PlatformError } from './errors.mjs'; + +const POLICY_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class PolicyEngine { + #policies = new Map(); + + register(id, evaluator, { owner = 'vendoo.security', description = '' } = {}) { + const normalized = String(id || ''); + if (!POLICY_ID.test(normalized)) throw new PlatformError(`Ungültige Policy-ID: ${normalized}`, { code: 'POLICY_ID_INVALID' }); + if (this.#policies.has(normalized)) throw new PlatformError(`Policy bereits registriert: ${normalized}`, { code: 'POLICY_DUPLICATE' }); + if (typeof evaluator !== 'function') throw new PlatformError(`Policy benötigt eine Prüffunktion: ${normalized}`, { code: 'POLICY_EVALUATOR_INVALID' }); + this.#policies.set(normalized, { id: normalized, evaluator, owner, description }); + } + + async evaluate(id, context) { + const policy = this.#policies.get(id); + if (!policy) { + return { allowed: false, reason: 'policy_not_registered', policy: id }; + } + const result = await policy.evaluator(Object.freeze({ ...context })); + if (result === true) return { allowed: true, policy: id }; + if (result === false || result == null) return { allowed: false, reason: 'denied', policy: id }; + return { allowed: Boolean(result.allowed), reason: result.reason || null, policy: id, details: result.details || null }; + } + + list() { + return [...this.#policies.values()].map(({ evaluator: _evaluator, ...policy }) => ({ ...policy })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/route-registry.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/route-registry.mjs new file mode 100644 index 0000000..bcc8cd0 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/kernel/route-registry.mjs @@ -0,0 +1,62 @@ +import { PlatformError, platformErrorPayload } from './errors.mjs'; + +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); +const ROUTE_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class RouteRegistry { + #routes = new Map(); + #policyEngine; + + constructor({ policyEngine }) { + this.#policyEngine = policyEngine; + } + + register(app, definition) { + const method = String(definition.method || '').toLowerCase(); + const id = String(definition.id || ''); + const path = String(definition.path || ''); + if (!ROUTE_ID.test(id)) throw new PlatformError(`Ungültige Route-ID: ${id}`, { code: 'ROUTE_ID_INVALID' }); + if (!METHODS.has(method)) throw new PlatformError(`Ungültige HTTP-Methode für ${id}.`, { code: 'ROUTE_METHOD_INVALID' }); + if (!path.startsWith('/')) throw new PlatformError(`Ungültiger Routenpfad für ${id}.`, { code: 'ROUTE_PATH_INVALID' }); + if (!definition.policy) throw new PlatformError(`Route ${id} benötigt eine explizite Policy.`, { code: 'ROUTE_POLICY_REQUIRED' }); + if (!definition.owner) throw new PlatformError(`Route ${id} benötigt einen Modulbesitzer.`, { code: 'ROUTE_OWNER_REQUIRED' }); + if (typeof definition.handler !== 'function') throw new PlatformError(`Route ${id} benötigt einen Handler.`, { code: 'ROUTE_HANDLER_REQUIRED' }); + const key = `${method.toUpperCase()} ${path}`; + if (this.#routes.has(key)) throw new PlatformError(`Route bereits registriert: ${key}`, { code: 'ROUTE_DUPLICATE' }); + + const contract = Object.freeze({ + id, + method: method.toUpperCase(), + path, + owner: String(definition.owner), + policy: String(definition.policy), + audit: String(definition.audit || ''), + rateLimit: String(definition.rateLimit || 'default'), + csrf: definition.csrf !== false, + stability: String(definition.stability || 'internal'), + }); + this.#routes.set(key, contract); + + app[method](path, ...(definition.middleware || []), async (req, res, next) => { + try { + const decision = await this.#policyEngine.evaluate(contract.policy, { req, user: req.user, route: contract }); + if (!decision.allowed) { + throw new PlatformError('Keine Berechtigung für diese Aktion.', { + code: 'POLICY_DENIED', status: 403, expose: true, + details: { policy: contract.policy, reason: decision.reason }, + }); + } + await definition.handler(req, res, { route: contract, decision }); + } catch (error) { + if (res.headersSent) return next(error); + const status = Number.isInteger(error?.status) ? error.status : 500; + res.status(status).json(platformErrorPayload(error, req.requestId)); + } + }); + return contract; + } + + list() { + return [...this.#routes.values()].map(route => ({ ...route })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/ai/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/ai/module.json new file mode 100644 index 0000000..27f0122 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/ai/module.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "vendoo.ai", + "name": "AI Services", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich AI Services; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security", + "vendoo.media" + ], + "permissions": [ + "generation.execute" + ], + "provides": [ + "ai.text", + "ai.images" + ], + "consumes": [ + "media.assets", + "security.policies" + ], + "owns": [ + "ai.providers", + "ai.jobs" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/auth/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/auth/module.json new file mode 100644 index 0000000..1d24f9b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/auth/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.auth", + "name": "Authentication", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Authentication; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security" + ], + "permissions": [ + "sessions.manage" + ], + "provides": [ + "auth.sessions", + "auth.identity" + ], + "consumes": [ + "security.policies" + ], + "owns": [ + "auth.sessions", + "auth.tokens" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/catalog.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/catalog.mjs new file mode 100644 index 0000000..ca5a5f1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/catalog.mjs @@ -0,0 +1,22 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { createThemesModule } from './themes/index.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const moduleNames = ['system', 'security', 'auth', 'users', 'media', 'listings', 'ai', 'flux-studio', 'quality', 'publishing', 'operations']; + +function legacyModule(name) { + return { + manifest: JSON.parse(readFileSync(join(here, name, 'module.json'), 'utf8')), + lifecycle: { + async start() {}, + async stop() {}, + async health() { return { ok: true, bridge: 'legacy' }; }, + }, + }; +} + +export function loadBuiltInModules() { + return [...moduleNames.map(legacyModule), createThemesModule()]; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/flux-studio/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/flux-studio/module.json new file mode 100644 index 0000000..463e56b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/flux-studio/module.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "vendoo.flux-studio", + "name": "FLUX Studio", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich FLUX Studio; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.ai" + ], + "permissions": [ + "generation.execute" + ], + "provides": [ + "flux.prompt-jobs" + ], + "consumes": [ + "ai.images" + ], + "owns": [ + "flux.generations", + "flux.history" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/listings/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/listings/module.json new file mode 100644 index 0000000..53d1da2 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/listings/module.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "vendoo.listings", + "name": "Listings & Inventory", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Listings & Inventory; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth", + "vendoo.media" + ], + "permissions": [ + "listings.view", + "listings.edit", + "listings.delete", + "inventory.edit" + ], + "provides": [ + "listings.catalog", + "inventory.stock" + ], + "consumes": [ + "media.assets", + "auth.identity" + ], + "owns": [ + "listings.records", + "inventory.locations" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/media/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/media/module.json new file mode 100644 index 0000000..53d3b2d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/media/module.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "vendoo.media", + "name": "Media Library", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Media Library; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth" + ], + "permissions": [ + "media.view", + "media.edit", + "media.delete" + ], + "provides": [ + "media.assets", + "media.derivatives" + ], + "consumes": [ + "auth.identity" + ], + "owns": [ + "media.library", + "media.uploads" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/operations/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/operations/module.json new file mode 100644 index 0000000..2e7ee25 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/operations/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.operations", + "name": "Operations Center", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Operations Center; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security" + ], + "permissions": [ + "operations.manage" + ], + "provides": [ + "operations.backup", + "operations.update" + ], + "consumes": [ + "security.audit" + ], + "owns": [ + "operations.backups", + "operations.updates" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/publishing/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/publishing/module.json new file mode 100644 index 0000000..d902367 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/publishing/module.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "vendoo.publishing", + "name": "Publishing", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Publishing; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.listings", + "vendoo.security" + ], + "permissions": [ + "publishing.view", + "publishing.execute", + "publishing.manage" + ], + "provides": [ + "publishing.jobs", + "publishing.adapters" + ], + "consumes": [ + "listings.catalog", + "security.policies" + ], + "owns": [ + "publishing.queue", + "publishing.events" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/quality/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/quality/module.json new file mode 100644 index 0000000..aa878d4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/quality/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.quality", + "name": "Quality Center", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Quality Center; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.listings", + "vendoo.ai" + ], + "permissions": [ + "quality.execute" + ], + "provides": [ + "quality.reports" + ], + "consumes": [ + "listings.catalog", + "ai.text" + ], + "owns": [ + "quality.history" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/security/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/security/module.json new file mode 100644 index 0000000..9fbfe04 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/security/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.security", + "name": "Security Core", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Security Core; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.system" + ], + "permissions": [ + "security.manage" + ], + "provides": [ + "security.policies", + "security.audit" + ], + "consumes": [ + "platform.events" + ], + "owns": [ + "security.permissions", + "security.rate-limits" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/system/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/system/module.json new file mode 100644 index 0000000..4c27489 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/system/module.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "id": "vendoo.system", + "name": "System Kernel", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich System Kernel; schrittweise Migration ohne Funktionsbruch.", + "requires": [], + "permissions": [ + "dashboard.view" + ], + "provides": [ + "platform.lifecycle", + "platform.events", + "platform.features" + ], + "consumes": [], + "owns": [ + "system.runtime", + "system.health" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/index.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/index.mjs new file mode 100644 index 0000000..ec510d4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/index.mjs @@ -0,0 +1,27 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { PlatformError } from '../../kernel/errors.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8')); +let service = null; + +export function getThemeService() { + if (!service) throw new PlatformError('Theme-Modul ist noch nicht gestartet.', { code: 'THEME_MODULE_NOT_READY', status: 503, expose: true }); + return service; +} + +export function createThemesModule() { + return { + manifest, + lifecycle: { + async start() { + const { createThemeService } = await import('./service.mjs'); + service = createThemeService(); + }, + async stop() { service = null; }, + async health() { return getThemeService().health(); }, + }, + }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/migrations/001_theme_profiles.sql b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/migrations/001_theme_profiles.sql new file mode 100644 index 0000000..d3207f3 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/migrations/001_theme_profiles.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS theme_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme TEXT NOT NULL DEFAULT 'light', + values_json TEXT NOT NULL DEFAULT '{}', + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS user_theme_preferences ( + user_id INTEGER PRIMARY KEY, + theme_id TEXT NOT NULL DEFAULT 'inherit', + density TEXT NOT NULL DEFAULT 'comfortable', + reduced_motion TEXT NOT NULL DEFAULT 'system', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/module.json new file mode 100644 index 0000000..41606c3 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/module.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "vendoo.themes", + "name": "Themes & Design System", + "version": "1.1.0", + "type": "feature", + "status": "native", + "description": "Natives Theme- und Design-System-Modul mit sicherem Token-Editor, Benutzerpräferenzen und Accessibility-Prüfungen.", + "requires": [ + "vendoo.system" + ], + "permissions": [ + "settings.manage", + "themes.use" + ], + "provides": [ + "themes.tokens", + "themes.runtime", + "themes.profiles", + "themes.accessibility" + ], + "consumes": [ + "platform.events" + ], + "owns": [ + "themes.definitions", + "themes.user-preferences", + "themes.system-default" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/repository.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/repository.mjs new file mode 100644 index 0000000..93f32fc --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/repository.mjs @@ -0,0 +1,95 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { db } from '../../../lib/db.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const migrationSql = readFileSync(join(here, 'migrations', '001_theme_profiles.sql'), 'utf8'); + +export function ensureThemeSchema() { + db.exec(migrationSql); +} + +function parseProfile(row) { + if (!row) return null; + let values = {}; + try { values = JSON.parse(row.values_json || '{}'); } catch {} + return { ...row, values }; +} + +export function listThemeProfiles() { + ensureThemeSchema(); + return db.prepare('SELECT * FROM theme_profiles ORDER BY name COLLATE NOCASE, id').all().map(parseProfile); +} + +export function getThemeProfile(id) { + ensureThemeSchema(); + return parseProfile(db.prepare('SELECT * FROM theme_profiles WHERE id = ?').get(id)); +} + +export function saveThemeProfile(profile, userId) { + ensureThemeSchema(); + db.prepare(`INSERT INTO theme_profiles (id, name, base_theme, values_json, created_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, base_theme = excluded.base_theme, + values_json = excluded.values_json, updated_at = datetime('now')`).run( + profile.id, profile.name, profile.base_theme, JSON.stringify(profile.values || {}), userId || null, + ); + return getThemeProfile(profile.id); +} + +export function deleteThemeProfile(id) { + ensureThemeSchema(); + const transaction = db.transaction(() => { + db.prepare("UPDATE user_theme_preferences SET theme_id = 'inherit', updated_at = datetime('now') WHERE theme_id = ?").run(id); + const system = db.prepare("SELECT value FROM settings WHERE key = 'theme_system_default'").get(); + if (system?.value === id) db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('theme_system_default', 'system')").run(); + return db.prepare('DELETE FROM theme_profiles WHERE id = ?').run(id).changes; + }); + return transaction(); +} + +export function getUserThemePreference(userId) { + ensureThemeSchema(); + return db.prepare('SELECT theme_id, density, reduced_motion, updated_at FROM user_theme_preferences WHERE user_id = ?').get(userId) || { + theme_id: 'inherit', density: 'comfortable', reduced_motion: 'system', updated_at: null, + }; +} + +export function saveUserThemePreference(userId, preference) { + ensureThemeSchema(); + db.prepare(`INSERT INTO user_theme_preferences (user_id, theme_id, density, reduced_motion) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET theme_id = excluded.theme_id, density = excluded.density, + reduced_motion = excluded.reduced_motion, updated_at = datetime('now')`).run( + userId, preference.theme_id, preference.density, preference.reduced_motion, + ); + return getUserThemePreference(userId); +} + +function getSetting(key, fallback) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row?.value ?? fallback; +} + +function setSetting(key, value) { + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, String(value)); +} + +export function getThemeSystemDefault() { + return { + theme_id: getSetting('theme_system_default', 'system'), + density: getSetting('theme_system_density', 'comfortable'), + reduced_motion: getSetting('theme_system_reduced_motion', 'system'), + }; +} + +export function saveThemeSystemDefault(preference) { + const transaction = db.transaction(() => { + setSetting('theme_system_default', preference.theme_id); + setSetting('theme_system_density', preference.density); + setSetting('theme_system_reduced_motion', preference.reduced_motion); + }); + transaction(); + return getThemeSystemDefault(); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/service.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/service.mjs new file mode 100644 index 0000000..3074d39 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/themes/service.mjs @@ -0,0 +1,85 @@ +import { PlatformError } from '../../kernel/errors.mjs'; +import { + evaluateThemeAccessibility, getBaseThemeValues, getBuiltinThemes, getThemeContract, + resolveThemeValues, validateThemeDefinition, validateThemePreference, +} from '../../core/themes/theme-contract.mjs'; +import { + deleteThemeProfile, ensureThemeSchema, getThemeProfile, getThemeSystemDefault, + getUserThemePreference, listThemeProfiles, saveThemeProfile, saveThemeSystemDefault, + saveUserThemePreference, +} from './repository.mjs'; + +function decorate(profile) { + return { + ...profile, + builtin: false, + resolved_values: resolveThemeValues({ baseTheme: profile.base_theme, values: profile.values }), + accessibility: evaluateThemeAccessibility({ baseTheme: profile.base_theme, values: profile.values }), + }; +} + +function customExists(id) { + return Boolean(getThemeProfile(id)); +} + +function effectivePreference(userPreference, systemDefault) { + return userPreference.theme_id === 'inherit' ? { ...systemDefault, inherited: true } : { ...userPreference, inherited: false }; +} + +export function createThemeService() { + ensureThemeSchema(); + return Object.freeze({ + health() { + ensureThemeSchema(); + return { ok: true, profiles: listThemeProfiles().length, contract: getThemeContract().contractVersion }; + }, + catalog(userId) { + const custom = listThemeProfiles().map(decorate); + const preference = getUserThemePreference(userId); + const systemDefault = getThemeSystemDefault(); + return { + contract: getThemeContract(), + builtin: getBuiltinThemes(), + custom, + preference, + system_default: systemDefault, + effective_preference: effectivePreference(preference, systemDefault), + }; + }, + validate(input) { + const theme = validateThemeDefinition(input, { allowExistingId: true }); + return { theme, resolved_values: resolveThemeValues({ baseTheme: theme.base_theme, values: theme.values }), accessibility: theme.accessibility }; + }, + create(input, userId) { + const theme = validateThemeDefinition(input); + if (getThemeProfile(theme.id)) throw new PlatformError('Theme-ID ist bereits vorhanden.', { code: 'THEME_DUPLICATE', status: 409, expose: true }); + return decorate(saveThemeProfile(theme, userId)); + }, + update(id, input, userId) { + if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + const theme = validateThemeDefinition({ ...input, id }, { allowExistingId: true }); + return decorate(saveThemeProfile(theme, userId)); + }, + remove(id) { + if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + deleteThemeProfile(id); + return { ok: true, id }; + }, + saveUserPreference(userId, input) { + const preference = validateThemePreference(input, { customThemeExists: customExists }); + return saveUserThemePreference(userId, preference); + }, + saveSystemDefault(input) { + const preference = validateThemePreference(input, { customThemeExists: customExists }); + if (preference.theme_id === 'inherit') throw new PlatformError('Der Systemstandard kann nicht auf „Vererben“ gesetzt werden.', { code: 'THEME_SYSTEM_INHERIT_INVALID', status: 400, expose: true }); + return saveThemeSystemDefault(preference); + }, + exportTheme(id) { + const profile = getThemeProfile(id); + if (!profile) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + return { schemaVersion: 1, type: 'vendoo-theme', theme: { id: profile.id, name: profile.name, base_theme: profile.base_theme, values: profile.values } }; + }, + }); +} + +export { getBaseThemeValues }; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/users/module.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/users/module.json new file mode 100644 index 0000000..16cd65a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/app/modules/users/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.users", + "name": "Users & Roles", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Users & Roles; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth" + ], + "permissions": [ + "users.manage" + ], + "provides": [ + "users.directory", + "users.roles" + ], + "consumes": [ + "auth.identity" + ], + "owns": [ + "users.accounts", + "users.preferences" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/bootstrap.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/bootstrap.mjs new file mode 100644 index 0000000..550339f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/bootstrap.mjs @@ -0,0 +1,25 @@ +import dotenv from 'dotenv'; +import { existsSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = dirname(fileURLToPath(import.meta.url)); +const rootEnvPath = join(appRoot, '.env'); + +// Lokaler Windows-/Entwicklungsbetrieb: klassische .env zuerst laden. +if (existsSync(rootEnvPath)) dotenv.config({ path: rootEnvPath, override: false }); + +// Docker/NAS/VPS: schreibbarer persistenter Overlay-Speicher für Eingabemasken. +const configuredPath = String(process.env.VENDOO_CONFIG_PATH || '').trim(); +const defaultPath = process.env.VENDOO_DATA_DIR + ? join(String(process.env.VENDOO_DATA_DIR), 'config', 'vendoo.env') + : rootEnvPath; +const configCandidate = configuredPath || defaultPath; +const runtimeConfigPath = resolve(isAbsolute(configCandidate) ? configCandidate : join(appRoot, configCandidate)); +process.env.VENDOO_CONFIG_PATH = runtimeConfigPath; + +if (runtimeConfigPath !== rootEnvPath && existsSync(runtimeConfigPath)) { + dotenv.config({ path: runtimeConfigPath, override: true }); +} + +await import('./server.mjs'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.bind-mounts.example.yaml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.bind-mounts.example.yaml new file mode 100644 index 0000000..c873c69 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.bind-mounts.example.yaml @@ -0,0 +1,11 @@ +# Optionaler Override für NAS-Bind-Mounts. +# Vorher ./runtime/{db,uploads,backups,operations,logs} anlegen und UID/GID 1000 Schreibrechte geben. +services: + vendoo: + volumes: + - ./runtime/db:/app/data/db + - ./runtime/uploads:/app/data/uploads + - ./runtime/backups:/app/data/backups + - ./runtime/operations:/app/data/operations + - ./runtime/logs:/app/data/logs + - ./runtime/config:/app/data/config diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.vps.yaml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.vps.yaml new file mode 100644 index 0000000..ad3a375 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.vps.yaml @@ -0,0 +1,106 @@ +name: vendoo +services: + vendoo: + build: + context: . + dockerfile: Dockerfile + image: vendoo:1.36.0 + container_name: vendoo + restart: unless-stopped + init: true + env_file: + - .env + environment: + NODE_ENV: production + PORT: 8124 + VENDOO_BIND_HOST: 0.0.0.0 + VENDOO_TRUST_PROXY: "true" + VENDOO_COOKIE_SECURE: "true" + VENDOO_DATA_DIR: /app/data + VENDOO_DB_PATH: /app/data/db/vendoo.db + VENDOO_UPLOADS_DIR: /app/data/uploads + VENDOO_BACKUP_DIR: /app/data/backups + VENDOO_OPERATIONS_DIR: /app/data/operations + VENDOO_LOG_DIR: /app/data/logs + VENDOO_CONFIG_PATH: /app/data/config/vendoo.env + VENDOO_REQUIRE_SETUP_TOKEN: "true" + LOCAL_IMAGE_ENABLED: "${LOCAL_IMAGE_ENABLED:-false}" + expose: + - "8124" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - vendoo_db:/app/data/db + - vendoo_uploads:/app/data/uploads + - vendoo_backups:/app/data/backups + - vendoo_operations:/app/data/operations + - vendoo_logs:/app/data/logs + - vendoo_config:/app/data/config + read_only: true + tmpfs: + - /tmp:size=512m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + stop_grace_period: 15s + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - vendoo + + caddy: + image: caddy:2-alpine + container_name: vendoo-caddy + restart: unless-stopped + depends_on: + vendoo: + condition: service_healthy + environment: + VENDOO_DOMAIN: "${VENDOO_DOMAIN}" + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + pids_limit: 128 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + networks: + - vendoo + +networks: + vendoo: + driver: bridge + +volumes: + vendoo_db: + vendoo_uploads: + vendoo_backups: + vendoo_operations: + vendoo_logs: + vendoo_config: + caddy_data: + caddy_config: diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.yaml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.yaml new file mode 100644 index 0000000..02f87fa --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/compose.yaml @@ -0,0 +1,72 @@ +name: vendoo +services: + vendoo: + build: + context: . + dockerfile: Dockerfile + image: vendoo:1.36.0 + container_name: vendoo + restart: unless-stopped + init: true + env_file: + - .env + environment: + NODE_ENV: production + PORT: 8124 + VENDOO_BIND_HOST: 0.0.0.0 + VENDOO_DATA_DIR: /app/data + VENDOO_DB_PATH: /app/data/db/vendoo.db + VENDOO_UPLOADS_DIR: /app/data/uploads + VENDOO_BACKUP_DIR: /app/data/backups + VENDOO_OPERATIONS_DIR: /app/data/operations + VENDOO_LOG_DIR: /app/data/logs + VENDOO_CONFIG_PATH: /app/data/config/vendoo.env + VENDOO_REQUIRE_SETUP_TOKEN: "true" + LOCAL_IMAGE_ENABLED: "${LOCAL_IMAGE_ENABLED:-false}" + ports: + - "${VENDOO_PUBLISH_ADDRESS:-127.0.0.1}:${VENDOO_HOST_PORT:-8124}:8124" + expose: + - "8124" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - vendoo_db:/app/data/db + - vendoo_uploads:/app/data/uploads + - vendoo_backups:/app/data/backups + - vendoo_operations:/app/data/operations + - vendoo_logs:/app/data/logs + - vendoo_config:/app/data/config + read_only: true + tmpfs: + - /tmp:size=512m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + stop_grace_period: 15s + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - vendoo + +networks: + vendoo: + driver: bridge + +volumes: + vendoo_db: + vendoo_uploads: + vendoo_backups: + vendoo_operations: + vendoo_logs: + vendoo_config: diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/db/schema.sql b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/db/schema.sql new file mode 100644 index 0000000..1702926 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/db/schema.sql @@ -0,0 +1,278 @@ +CREATE TABLE IF NOT EXISTS listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + ai_provider TEXT NOT NULL, + title TEXT, + description TEXT, + description_html TEXT, + tags TEXT, + photos TEXT, + seller_notes TEXT, + brand TEXT, + category TEXT, + size TEXT, + color TEXT, + condition TEXT, + price REAL, + status TEXT DEFAULT 'active', + language TEXT DEFAULT 'de', + created_by INTEGER, + updated_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + platform TEXT, + seller_notes TEXT, + language TEXT DEFAULT 'de', + default_tags TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS publish_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + status TEXT DEFAULT 'pending', + external_id TEXT, + external_url TEXT, + offer_id TEXT, + sync_status TEXT, + last_synced_at TEXT, + ended_at TEXT, + updated_at TEXT, + published_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publish_log_external ON publish_log(platform, external_id); + +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_platform', 'vinted'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_ai', 'claude'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('seller_notes', ''); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_language', 'de'); + +CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at); + + +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photos_enabled', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_variants', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_preset', 'mixed'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_underwear', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_kids', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_provider', 'openai'); + +CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id); + +CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (generation_id) REFERENCES ai_model_generations(id) ON DELETE CASCADE, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id); + + +CREATE TABLE IF NOT EXISTS flux_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + image_path TEXT NOT NULL UNIQUE, + width INTEGER, + height INTEGER, + seed INTEGER, + steps INTEGER, + style TEXT DEFAULT 'none', + edited_from INTEGER, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_flux_images_created ON flux_images(created_at DESC); + + +CREATE TABLE IF NOT EXISTS quality_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + grade TEXT NOT NULL DEFAULT 'E', + ready INTEGER NOT NULL DEFAULT 0, + blockers INTEGER NOT NULL DEFAULT 0, + warnings INTEGER NOT NULL DEFAULT 0, + recommendations INTEGER NOT NULL DEFAULT 0, + report_json TEXT NOT NULL DEFAULT '{}', + analyzed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_quality_reports_listing ON quality_reports(listing_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_quality_reports_score ON quality_reports(score, analyzed_at DESC); + + +CREATE TABLE IF NOT EXISTS publishing_jobs ( + id TEXT PRIMARY KEY, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'api', + action TEXT NOT NULL DEFAULT 'publish', + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + idempotency_key TEXT NOT NULL UNIQUE, + external_id TEXT, + external_url TEXT, + offer_id TEXT, + retries INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_attempt_at TEXT, + claimed_by TEXT, + claimed_at TEXT, + error_code TEXT, + error_message TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publishing_jobs_status ON publishing_jobs(status, next_attempt_at, created_at); +CREATE INDEX IF NOT EXISTS idx_publishing_jobs_listing ON publishing_jobs(listing_id, platform, created_at DESC); + +CREATE TABLE IF NOT EXISTS publishing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES publishing_jobs(id) ON DELETE SET NULL, + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publishing_events_job ON publishing_events(job_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_publishing_events_listing ON publishing_events(listing_id, id DESC); + + +-- Mehrbenutzer- und Sicherheitsgrundlage 1.31.0 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'editor', + active INTEGER NOT NULL DEFAULT 1, + avatar_color TEXT, + last_active_at TEXT, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + password_changed_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + invited_by INTEGER +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + ip TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + acquired_at TEXT NOT NULL DEFAULT (datetime('now')), + renewed_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + client_label TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(resource_type, resource_id) +); +CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at); +CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at); + +-- Natives Theme-Modul 1.36.0 +CREATE TABLE IF NOT EXISTS theme_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme TEXT NOT NULL DEFAULT 'light', + values_json TEXT NOT NULL DEFAULT '{}', + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE TABLE IF NOT EXISTS user_theme_preferences ( + user_id INTEGER PRIMARY KEY, + theme_id TEXT NOT NULL DEFAULT 'inherit', + density TEXT NOT NULL DEFAULT 'comfortable', + reduced_motion TEXT NOT NULL DEFAULT 'system', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/deploy/Caddyfile b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/deploy/Caddyfile new file mode 100644 index 0000000..9855bbd --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/deploy/Caddyfile @@ -0,0 +1,16 @@ +{ + admin off +} + +{$VENDOO_DOMAIN} { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + reverse_proxy vendoo:8124 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_32_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_32_0.md new file mode 100644 index 0000000..58e2347 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_32_0.md @@ -0,0 +1,60 @@ +# Vendoo 1.32.0 – Abnahmebericht + +**Release:** Docker, NAS & Production Deployment Foundation +**Prüfdatum:** 09.07.2026 +**Ausgangsbasis:** `Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip` +**Basis-SHA-256:** `cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b` + +## Ergebnis + +Vendoo 1.32.0 ist als vollständiges Drüberkopierpaket erstellt. Die Code-, Migrations-, Pfad-, Sicherheits- und Paketprüfungen sind bestanden. Die reale Docker-/NAS-, Windows- und Browser-End-to-End-Abnahme konnte in der bereitgestellten Linux-Sandbox nicht ausgeführt werden, weil dort weder Docker noch PowerShell/Windows noch ein Browser zur Verfügung stehen und das native `better-sqlite3`-Modul in dieser stark begrenzten Build-Umgebung nicht innerhalb des Ausführungszeitlimits kompiliert werden konnte. + +Der Release ist deshalb **für Installation und reale Umgebungsabnahme vorbereitet**, aber Docker-, Windows- und Browserbetrieb werden nicht fälschlich als praktisch getestet ausgewiesen. + +## Bestandene Prüfungen + +| Prüfung | Status | Ergebnis/Nachweis | +|---|---|---| +| Handover-Reihenfolge | bestanden | `00_START_HIER.md` bis `14_SOURCE_PACKAGE_CHECKSUM.txt` vollständig in vorgegebener Reihenfolge gelesen | +| Basis-ZIP SHA-256 | bestanden | stimmt exakt mit Handover überein | +| Basis-Dateiprüfsummen | bestanden | 271 von 271 Dateien `OK` | +| Reale 1.31.0-Codeanalyse | bestanden | RBAC, Sessions, Audit, Locks, Setup, Windows-Menü, FLUX-Trennung und reale Deployment-Lücken geprüft | +| Versionssynchronität | bestanden | Paket, Lockfile, Manifest, UI-Build und Windows-Setup = `1.32.0` | +| JS-/MJS-Syntax | bestanden | alle geänderten Laufzeitdateien sowie Verifikator mit `node --check` | +| Shell-Syntax | bestanden | Entry-Point, Backup und Restore mit `sh -n` | +| YAML-Syntax | bestanden | `compose.yaml` und Bind-Mount-Override mit PyYAML geladen | +| Datenbankmigration frisch | bestanden | 68 additive DB-Ausführungsblöcke; Pflichttabellen vorhanden | +| Datenbankmigration bestehend | bestanden | vorhandener Testdatensatz blieb erhalten; Publishing- und Operations-Struktur gültig | +| Lockfile-Auflösung | bestanden | sauberer `npm ci --ignore-scripts`: 151 Pakete installiert | +| Laufzeitpfade | bestanden | DB, Uploads, Backups, Operations und Logs in getrenntem temporären Datenbereich beschreibbar | +| Setup-Token Entry-Point | bestanden | fehlender Token beendet Produktion mit Exit 78; 32-Zeichen-Token akzeptiert | +| Deployment-Gate | bestanden | Version, Pfadtrennung, Readiness, Shutdown, Setup-Schutz, Origins, Container-Härtung und Datenhygiene | +| Datenhygiene | bestanden | keine `.env`, DB, Logs, Uploads, Backups, Operations, Modelle oder `node_modules` im Anwendungspaket | +| Secret-Musterscan | bestanden | keine erkannten privaten Schlüssel, AWS- oder `sk-`-Live-Schlüssel | +| FLUX/VTO-Regel | bestanden | FLUX bleibt getrennt; Hauptcontainer standardmäßig deaktiviert; freier Workflow ohne `LoadImage`; kein VTO-Wiedereinbau | +| Originalbildschutz | bestanden | geänderte Pfadlogik erzeugt keine destruktive Originalüberschreibung | + +## Nicht ausgeführte beziehungsweise nicht vollständig ausführbare Prüfungen + +| Prüfung | Status | Grund | +|---|---|---| +| vollständiges `npm ci` mit nativen Install-Skripten | nicht abgeschlossen | Registry-/GitHub-Prebuild war nicht verfügbar; lokaler `better-sqlite3`-Fallback kompilierte in der Sandbox auch nach 5 bzw. 10 Minuten nicht fertig | +| Serverstart und API-Smoke-Test | nicht ausgeführt | natives `better-sqlite3`-Binary dadurch nicht verfügbar | +| Docker-Build | nicht ausgeführt | `docker` ist in der Sandbox nicht installiert (`exit 127`) | +| `docker compose config` über Docker CLI | nicht ausgeführt | Docker fehlt; YAML wurde statisch erfolgreich validiert | +| Container-Healthcheck/Persistenz/Backup/Restore | nicht ausgeführt | Docker fehlt; Skripte und Mount-Logik wurden statisch geprüft | +| Reverse-Proxy-/HTTPS-Livetest | nicht ausgeführt | keine Proxy-/TLS-Laufzeit vorhanden | +| Windows-Start/Update | nicht ausgeführt | kein Windows und kein PowerShell vorhanden; reale Menüpunkte 2 und 3 statisch bestätigt | +| Login, Session, Logout, Rollen, Upload, Artikel, SmartCopy, Publish-Center | nicht ausgeführt | Server-/Browserlaufzeit nicht verfügbar | +| Dark Mode, Responsive, Tastatur/Fokus | nicht ausgeführt | kein Browser-End-to-End-Test; dieser Slice verändert keine Haupt-UI-Layouts, nur das Setup-Token-Feld im Login | +| echte Plattform-APIs | nicht ausgeführt | bewusst keine Veröffentlichung ausgelöst | + +## Reale Abnahme nach Installation + +Der Betreiber muss die in `INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md` beschriebene Funktionsprüfung ausführen. Besonders wichtig sind `/readyz`, Login/Logout, Rollen, bestehende Artikel und Bilder, Upload, Containerneustart, Testbackup, Restore in einem sicheren Testpfad sowie ein Reverse-Proxy-Test mit HTTPS. + +## Bekannte Folgepunkte + +- Widerrufbare, minimale Browser-Extension-Tokenkopplung statt dauerhafter Cookie-Abhängigkeit bleibt ein späterer Security-Slice. +- Offline-Notfallverfahren für den verlorenen letzten Administrator bleibt ein späterer Operations-Slice. +- Ein veröffentlichtes, signiertes Container-Image und automatisierte CI-Builds sind noch nicht Bestandteil dieses Pakets. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_33_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_33_0.md new file mode 100644 index 0000000..dc1773d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_33_0.md @@ -0,0 +1,34 @@ +# Vendoo 1.33.0 – Abnahmebericht + +**Release:** Multiplatform Deployment Wizard & Flux/Admin Fixes +**Prüfdatum:** 09.07.2026 +**Basis:** Vendoo 1.32.0 + +## Bestandene statische Prüfungen + +- Versionsgleichstand in Paket, Lockfile, UI, Compose, Manifest und Windows-Setup +- JavaScript-/MJS-Syntax der geänderten Dateien +- POSIX-Shell-Syntax von `setup.sh` +- Deployment-Gate 1.33.0 +- Windows-Assistent enthält alle vier Zieltypen +- Linux-Assistent enthält Docker-, NAS- und VPS-Modus +- Compose behält non-root, read-only, Healthcheck und getrennte Volumes +- FLUX-Historie wählt beim Laden kein Bild mehr automatisch aus +- Reset-Funktion löscht weder Verlauf noch Bilder noch Jobs +- Benutzeraktionen verwenden gebundene Event-Listener +- Passwortvalidierung findet vor dem Benutzer-Update statt +- Benutzer-Update liefert einen sanitiserten Datensatz +- keine produktive `.env`, DB, Uploads, Backups, Logs, Operations oder `node_modules` im Release + +## Nicht als praktisch getestet ausgewiesen + +- Windows PowerShell 5.1 Laufzeit, da die Sandbox kein Windows/PowerShell enthält +- Docker-Build und Containerstart, da kein Docker-Daemon vorhanden ist +- Portainer-Import und herstellerspezifische NAS-Oberflächen +- realer VPS-Reverse-Proxy und TLS +- Browser-End-to-End-Klicktest +- native Serverlaufzeit mit `better-sqlite3` + +## Abnahmeentscheidung + +Das Paket ist als Drüberkopier-Release und als geführte Installationsgrundlage vorbereitet. Die reale Zielsystemabnahme muss nach `02_INSTALLATION_UND_ABNAHME.md` erfolgen. Kein nicht ausgeführter Laufzeittest wird als bestanden dargestellt. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_0.md new file mode 100644 index 0000000..c79f13a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_0.md @@ -0,0 +1,50 @@ +# Vendoo 1.34.0 – Abnahmebericht + +**Release:** GitHub CI/CD & Zero-Edit Installer Foundation +**Prüfdatum:** 09.07.2026 +**Basis:** Vendoo 1.33.0 + +## Umgesetzte Abnahmepunkte + +- geführte Windows-Installationsmaske für lokalen Betrieb und Docker Desktop, +- geführter Shell-Assistent für Linux, macOS, NAS/Portainer und VPS, +- keine manuelle Bearbeitung von `.env`, Compose oder Caddy-Konfiguration erforderlich, +- automatisch erzeugter Erst-Admin-Code, +- Eingabevalidierung für Port, URL und Domain, +- automatisches VPS-HTTPS über getrennten Caddy-Stack, +- persistenter Runtime-Konfigurationsspeicher für Einstellungen im read-only Container, +- CI für reproduzierbare Installation, Syntax, Migrationen, Git-Sicherheit, Compose und Docker-Testbuild, +- Release-Automation für ZIP, SHA-256 und GHCR-Images, +- kontrollierter Git-Erstimport mit lokalem Sicherheitsgate, +- real angelegter Branch `release/1.34.0-initial-import`, +- real angelegter Draft Pull Request #3. + +## Bestandene Prüfungen + +- JavaScript-/MJS-Syntaxprüfung, +- POSIX-Shell-Syntaxprüfung, +- simulierte Installationsläufe für Docker lokal, NAS-Bind-Mount und VPS/Caddy, +- dabei erkannter und behobener Standardwert-/Prompt-Fehler im Shell-Assistenten, +- YAML-Parsing aller Compose- und GitHub-Workflow-Dateien, +- Deployment-Gate 1.34.0, +- Git-Sicherheitsgate, +- Datenbankmigrationstest gegen frische und bestehende Testdatenbank, +- bestehender Testdatensatz blieb erhalten, +- persistenter Konfigurationsspeicher einschließlich Dateirechten und Mehrzeilen-Sperre, +- Versionsgleichstand von Paket, Lockfile, UI, Manifest, Compose und Installer, +- kein produktives Secret oder Laufzeitdatum im bereinigten Release. + +## Nicht als praktisch getestet ausgewiesen + +- tatsächlicher WinForms-/PowerShell-5.1-Lauf auf Windows, +- Docker-Build und Containerstart mit realem Docker-Daemon, +- Docker Desktop auf Windows/macOS, +- Portainer-Import auf einem realen NAS, +- Caddy-Zertifikatsausstellung mit echter Domain und öffentlichem DNS, +- Browser-End-to-End-Abnahme, +- vollständige native Node-Laufzeit mit kompiliertem `better-sqlite3`, +- veröffentlichter GitHub-Release-Workflow und GHCR-Push. + +## Abnahmeentscheidung + +Das Release ist als vollständiges Drüberkopier-Paket, Zero-Edit-Installationsgrundlage und kontrollierte GitHub-Importbasis freigegeben. Zielsystemtests bleiben gemäß Installations- und Abnahmeanleitung verpflichtend. Nicht ausgeführte Laufzeittests werden nicht als bestanden dargestellt. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_1.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_1.md new file mode 100644 index 0000000..cf7bd88 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_1.md @@ -0,0 +1,20 @@ +# Vendoo 1.34.1 – Abnahmebericht + +## Bestanden + +- JavaScript-Syntax des Git-Sicherheitsgates und des Regressionstests +- vollständiger Arbeitsbaumscan auf sauberem Release-Quellstand +- Blockierung unversionierter `.env.local`- und Token-Testdateien +- erfolgreicher Lauf nach Entfernung der Test-Secrets +- Deployment-Gate 1.34.1 +- Shell-Syntax des Linux/macOS-Deploy-Assistenten +- Versionsgleichstand in Paket, Lockfile, UI, Setup und Manifest +- GitHub-Branch `release/1.34.1-initial-import` und Draft Pull Request #4 real angelegt +- Pull Request #3 geschlossen und als ersetzt dokumentiert + +## Nicht als real getestet ausgewiesen + +- vollständiger Lauf unter Windows PowerShell 5.1 +- echte Browser-Anmeldung und kompletter Push vom Nutzer-PC +- GitHub Actions nach vollständigem Quellimport +- Docker-/NAS-/VPS-Laufzeit, da dieser Hotfix ausschließlich das Git-Sicherheitsgate betrifft diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_2.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_2.md new file mode 100644 index 0000000..06b8647 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_2.md @@ -0,0 +1,25 @@ +# Vendoo 1.34.2 – Abnahmebericht + +## Bestanden + +- JavaScript-Syntax von Staging-Builder, Staging-Regressionstest, Sicherheitsgate und Deployment-Gate +- Shell-Syntax des Linux/macOS-Deploy-Assistenten +- YAML-Parsing von CI, Release und Compose-Dateien +- Git-Staging-Regressionstest: sichere Dateien kopiert, lokale `.env.local`, Operations-, Log-, Upload-, DB- und Modelltestdaten ausgeschlossen +- `.git` des temporären Klons blieb unverändert erhalten +- Git-Sicherheitsgate auf dem vollständigen Release-Quellstand +- Secret-Regressionstest mit unversionierten Testdateien +- Deployment-Gate 1.34.2 +- Shell-Installer-Simulation für Docker, NAS und VPS +- Datenbankmigrationen: 68 Einheiten auf frischer und bestehender Datenbank +- vorhandener Testdatensatz blieb erhalten +- Versionsgleichstand in Paket, Lockfile, UI, Setup, Compose und Update-Manifest + +## Nicht als real getestet ausgewiesen + +- vollständiger Lauf unter Windows PowerShell 5.1 +- echte GitHub-Browseranmeldung und kompletter Push vom Nutzer-PC +- GitHub Actions nach vollständigem Quellimport +- echter Docker-/NAS-/VPS-Lauf + +Die Windows-spezifische Prozessausgabe wurde statisch geprüft; eine echte Windows-Laufzeit steht in der Erstellungsumgebung nicht zur Verfügung. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_3.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_3.md new file mode 100644 index 0000000..db82b5d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_34_3.md @@ -0,0 +1,20 @@ +# Vendoo 1.34.3 – Abnahmebericht + +## Bestanden + +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1` +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/setup.bat` +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/setup.ps1` +- aktive Root-/scripts-Installationsdateien bleiben im Staging +- Git-Sicherheitsgate auf bereinigtem Quellstand +- JavaScript-/MJS-Syntaxprüfung +- Deployment-Gate 1.34.3 +- Shell-Installer-Simulation +- Datenbankmigrationsprüfung gegen frische und bestehende Testdatenbank + +## Nicht als real getestet + +- Windows PowerShell 5.1 auf dem Benutzerrechner +- echter Push aus dem lokalen Projektordner +- Docker-/Portainer-/VPS-Livetest +- GitHub-Actions-Lauf nach dem vollständigen Import diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_35_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_35_0.md new file mode 100644 index 0000000..f8552e6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_35_0.md @@ -0,0 +1,53 @@ +# Vendoo 1.35.0 – Abnahmebericht + +## Bestanden + +- JavaScript-/MJS-Syntax aller bestehenden und neuen Kernbestandteile +- Plattform-Architecture-Gate +- zwölf Modulmanifeste und Abhängigkeiten +- Zykluserkennung +- fehlende Capability wird blockiert +- doppelte Ressourcen-Ownership wird blockiert +- Deny-by-default-Route ohne Policy wird blockiert +- nicht berechtigter Zugriff auf registrierte Route ergibt HTTP 403 +- Event-Auslieferung und Owner-Cleanup +- Lifecycle- und Modulshutdown +- sichere Theme-Werte und verbotene Token +- RGB-Bereichsprüfung +- deterministische Token-Generierung +- Light, Dark und High Contrast vorhanden +- Safe-DOM-Vertrag ohne `innerHTML` +- API-Client-Vertrag mit CSRF und Same-Origin +- Deployment-Gate 1.35.0 +- Git-Sicherheitsgate: 365 Dateien ohne Secrets/Laufzeitdaten +- Git-Sicherheits-Regressionstest +- Git-Staging-Regressionstest +- persistenter Konfigurationsspeicher +- Shell-Installer-Simulation für Docker, NAS und VPS +- 68 Migrationseinheiten gegen frische Datenbank +- 68 Migrationseinheiten gegen bestehende Datenbank +- vorhandener Testdatensatz blieb erhalten + +## Dependency-Test + +`npm ci` wurde versucht. Die Lockdatei wurde aufgelöst, aber `better-sqlite3` konnte in der Sandbox nicht fertig gebaut werden: + +1. Prebuild-Download scheiterte wegen fehlender DNS-/Netzverbindung zu GitHub. +2. Der Fallback mit lokal vorhandenen Node-Headern begann erfolgreich zu kompilieren, wurde aber beim SQLite-C-Build durch das Ausführungslimit beendet. + +Dieser Punkt wird nicht als bestanden ausgegeben. + +## Nicht praktisch getestet + +- Windows PowerShell 5.1 und WinForms +- echter Windows-Lokalstart +- echter Docker-/NAS-/Portainer-Start +- VPS/Caddy/TLS +- Browser-End-to-End-Test +- visuelle Regression aller Seiten +- vollständiger Node-Serverstart mit nativ kompiliertem `better-sqlite3` +- GitHub Actions und GHCR-Liveausführung + +## Daten- und Kompatibilitätsaussage + +Keine Datenbank, Uploads, Backups, Operations-Daten, Benutzer, Artikel, API-Schlüssel, FLUX-Modelle oder ComfyUI-Daten wurden in das Paket aufgenommen oder gelöscht. Es gibt keine Framework- oder Datenbankmigration. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_36_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_36_0.md new file mode 100644 index 0000000..ef23feb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/ABNAHMEBERICHT_1_36_0.md @@ -0,0 +1,32 @@ +# Abnahmebericht Vendoo 1.36.0 + +## Gegenstand + +Secure Theme Manager, Accessibility & Component Token Foundation. + +## Erfolgreich geprüft + +- JavaScript-/MJS-Syntax +- Plattform-Architektur und zwölf Modulmanifeste +- natives Theme-Modul +- 42 Design Tokens und drei integrierte Themes +- Theme-Validierung und ungültige CSS-/Script-Werte +- Deny-by-default Theme-Routen +- Benutzer-/Administrator-Policy-Trennung +- Theme-Tabellen auf frischer und bestehender Datenbank +- Git-Sicherheitsgate mit 385 geprüften Dateien und Staging-Filter +- Windows-/Docker-/NAS-/VPS-Deploymentverträge +- reproduzierbare Paketauflösung mit 151 Paketen über `npm ci --ignore-scripts` +- Release-Staging mit 384 Einzeldatei-Prüfsummen + +## Nicht als real ausgeführt gewertet + +- Browser-End-to-End-Test unter echtem Windows +- Docker-/NAS-/VPS-Livestart +- Caddy-/TLS-Livetest +- GitHub-Actions-Livelauf +- native Kompilierung/Ausführung von `better-sqlite3` in der Sandbox; `npm ci --ignore-scripts` war erfolgreich, native Installationsskripte wurden bewusst nicht ausgeführt + +## Ergebnis + +Die statische, architektonische und migrationsbezogene Abnahme ist bestanden. Die genannten plattformspezifischen Laufzeittests sind nach Installation in der Zielumgebung durchzuführen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md new file mode 100644 index 0000000..4c5e376 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md @@ -0,0 +1,33 @@ +# Vendoo 1.31.0 – Ist- und Lückenanalyse + +Stand der Prüfung: 09.07.2026 +Geprüfte Basis: `Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip` +Verifizierte SHA-256 der Basis: `cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b` + +Die Bewertung basiert auf dem real enthaltenen Code. Paketnamen und Dokumentation wurden nicht als alleiniger Funktionsnachweis verwendet. + +| Bereich | Status in 1.31.0 | Reale Fundstelle | Risiko/Lücke | Änderung in 1.32.0 | +|---|---|---|---|---| +| Versionen | bestätigt | `package.json`, Lockfile, UI-Build und Manifest = 1.31.0 | keine | auf 1.32.0 synchronisiert | +| Rollen/RBAC | bestätigt | `lib/security.mjs`, globaler `apiPermissionGuard` | einzelne Sonderrouten bleiben zusätzlich manuell zu beobachten | unverändert erhalten | +| Sessions/Login-Schutz | bestätigt, Setup-Ausnahme riskant | `lib/auth.mjs`, gehashte Tokens, Sperrlogik, Widerruf; `requireAuth` übersprang im Setup-Modus alle APIs | vor erstem Admin waren geschützte API-Routen unnötig offen; kein Offline-Notfallreset für verlorenen letzten Admin | API im Setup-Modus gesperrt; Notfallreset als Folgepunkt dokumentiert | +| CSRF/Cookies | vorhanden | Double-Submit-Cookie und Header, `HttpOnly`, dynamisches `Secure`, `SameSite=lax` | Cookie-Modus nicht konfigurierbar; Proxyannahme nur boolesch/ein Hop | Cookie-Modus konfigurierbar, Proxy-Hops/Scopes unterstützt | +| CORS/Origin | vorhanden, unvollständig | Host-/Origin-Middleware in `server.mjs` | jede Browser-Extension-Origin wurde pauschal akzeptiert | in Produktion nur konkrete Extension-Origins, unsicherer Opt-in separat | +| Audit/Locks | bestätigt | Audit-Log, `resource_locks`, Adminansicht | keine kritische Deployment-Lücke | erhalten | +| Health | teilweise | `/healthz` liefert Prozessstatus | keine Readiness-, DB- oder Schreibpfadprüfung | `/readyz` mit DB- und Volume-Schreibtest | +| Shutdown | fehlt | direkter `app.listen()` ohne Signalbehandlung | WAL/Jobs können beim Containerstopp hart beendet werden | SIGTERM/SIGINT, Server-Close, WAL-Checkpoint, DB-Close | +| Datenbankpfad | unvollständig | fest `db/vendoo.db` im Codeordner | Container-Image und Nutzerdaten nicht sauber trennbar | `VENDOO_DB_PATH` und zentrale Laufzeitpfade | +| Uploadpfad | unvollständig | fest `uploads/` im Codeordner | keine getrennte Persistenz; öffentlich statisch erreichbar | konfigurierbarer Pfad, Zugriff hinter Anmeldung | +| Uploadprüfung | Sicherheitsrisiko | Dateiendung **oder** `image/*` genügte | manipulierbarer MIME-Typ konnte beliebige Endungen umgehen | Endung **und** Bild-MIME erforderlich; Größenlimit bleibt aktiv | +| Backups | bestätigt, aber codegebunden | verifizierte ZIPs mit DB/Uploads in `operations-center.mjs` | Pfade fest an Projektroot; Live-Volume-Kopie nicht dokumentiert | separate Backup-/Operations-Volumes und sichere Stop/Backup/Start-Anleitung | +| Restore/Update | vorhanden, gestuft | Operations Center und `setup.ps1` | Container darf Anwendungscode nicht in-place überschreiben | Docker-Update nur über neues Image; Windows-Update bleibt erhalten | +| Docker/Compose | fehlt | keine entsprechenden Dateien | kein reproduzierbarer NAS-/Produktionsbetrieb | Dockerfile, Compose, Healthcheck, non-root, read-only, Volumes | +| Reverse Proxy | teilweise | Host/Origin, `PUBLIC_BASE_URL`, `trust proxy` | kein fertiger Produktionsleitfaden | HTTPS-/Proxy-Dokumentation und sichere Standardbindung | +| FLUX/ComfyUI | getrennt im Konzept | externer URL-Endpunkt; lokaler Windows-Installer | Bootstrap schreibt in Projektordner und passt nicht zu read-only Container | Container startet standardmäßig ohne FLUX; externer Dienst bleibt optional | +| Browser-Erweiterungen | real vorhanden | Chrome/Edge/Firefox/Safari-Strukturen und Pakete | Remote-Kopplung nutzt weiterhin Login-Cookie statt widerrufbarem Extension-Token | CORS gehärtet; Tokenkopplung bleibt geplanter 1.34.0-Slice | +| UI-Begriffe/Kernbereiche | weitgehend bestätigt | Navigation, Artikel, Galerie, SmartCopy, Publish und Erweiterungen im realen HTML/JS | vollständige Browser-End-to-End-Abnahme nicht statisch beweisbar | keine unnötige UI-Neuentwicklung in diesem Deployment-Slice | +| VTO | entfernt | kein aktiver FASHN-/VTO-Dienst; freier FLUX-Workflow ohne `LoadImage` | historische Dokumente enthalten weiterhin Kontext | keine VTO-Rückkehr | + +## Ergebnis + +1.31.0 besitzt eine echte Mehrbenutzer- und Sicherheitsgrundlage, war aber noch kein sauber containerisierbarer Produktionsstand. Die Hauptlücken lagen in der festen Kopplung von Code und Nutzerdaten, fehlender Readiness, fehlendem geordneten Shutdown, fehlenden Containerdateien sowie zu breiten Extension-Origin- und Uploadregeln. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/BRANDING.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/BRANDING.md new file mode 100644 index 0000000..2aafd18 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/BRANDING.md @@ -0,0 +1,23 @@ +# Vendoo Branding + +Die Anwendung verwendet ab Version 1.6.2 ein gemeinsames Vendoo-Branding für Web-App, Login, mobilen Foto-Upload und Browser-Erweiterungen. + +## Web-App + +Assets liegen unter `public/brand/`: + +- `logo/` – horizontales Logo, transparente und zugeschnittene Varianten +- `icons/` – App- und Mobilgrößen von 16 bis 512 Pixel +- `favicons/` – ICO, PNG-Favicons, Apple Touch Icon, Android-Icons und Webmanifest + +## Browser-Erweiterungen + +Chrome, Edge und Firefox verwenden dieselbe Icon-Familie in 16, 32, 48 und 128 Pixeln. Popup, Browser-Toolbar, Vinted Assistant und minimierter Assistant verwenden das neue Markenzeichen. + +## Farben + +- Vendoo Orange: `#E63B17` +- Graphit: `#22201D` +- Warmes Off-White: `#FAF6F2` + +Die Proportionen und Farben der bereitgestellten Assets sollen nicht manuell verändert werden. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/FULL_LISTING_EDITOR_2026.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/FULL_LISTING_EDITOR_2026.md new file mode 100644 index 0000000..f9cea38 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/FULL_LISTING_EDITOR_2026.md @@ -0,0 +1,29 @@ +# Vendoo Full Listing Editor 2026 + +## Ziel + +Bereits gespeicherte Artikel werden nicht mehr in einem reduzierten Detailformular, sondern in einem vollständigen Arbeitsbereich bearbeitet. Generator und Listing-Editor verwenden dieselbe Rich-Text- und HTML-Logik. + +## Funktionsumfang + +- vollständige Bearbeitung aller Listing-Felder +- Plattform, AI-Provider, Sprache, Status, SKU und Lagerort +- Titel, Tags, Verkäufernotizen und sämtliche erkannten Attribute +- Preis und Kategorie +- Foto hinzufügen, entfernen, sortieren und als Cover festlegen +- vorhandene Fotos mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- WYSIWYG und HTML-Code gleichzeitig sichtbar +- Änderungen im WYSIWYG erscheinen sofort im HTML-Code +- Änderungen im HTML-Code werden in den WYSIWYG-Editor zurückgeführt +- separate sandboxed Plattformvorschau +- Editor-HTML und Plattform-HTML separat kopierbar + +## Datenmodell + +`listings.description_html` speichert das bereinigte Rich-Text-HTML. `listings.description` bleibt als Klartext-Fallback und für Plattformen beziehungsweise Exporte erhalten. + +Die neue Spalte wird über das bestehende `MIGRATIONS`-Array in `lib/db.mjs` ergänzt. + +## Sicherheit + +HTML wird auf unterstützte Formatierungs-Tags reduziert. Skripte, eingebettete Frames, Formulare, Event-Handler und gefährliche URLs werden entfernt. Vorschauen werden in einem `sandbox`-iframe dargestellt. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GENERATOR_STUDIO_2026.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GENERATOR_STUDIO_2026.md new file mode 100644 index 0000000..720bc55 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GENERATOR_STUDIO_2026.md @@ -0,0 +1,61 @@ +# Vendoo Listing Studio 2026 + +## Ziel + +Der Listing-Generator ist kein Formularstapel mehr, sondern ein zusammenhängender Studio-Arbeitsplatz mit drei klaren Zonen: + +1. Fotos & Medien +2. AI-Konfiguration +3. Vorschau & Bearbeitung + +Die bestehende Vanilla-JS-, Express- und SQLite-Architektur bleibt erhalten. + +## Fotos & Medien + +- großes Cover mit vollständiger Bilddarstellung über `object-fit: contain` +- Klick auf ein Vorschaubild setzt es als Cover +- Drag & Drop sortiert die Bildreihenfolge +- maximal zehn Bilder pro Listing +- sichtbarer Foto-Check mit Qualitätsindikatoren +- bestehender Bildeditor, Hintergrundentfernung und Wasserzeichen bleiben integriert + +## AI-Konfiguration + +- Plattform, Provider und Modell +- Sprache und Anzahl der Varianten +- Vorlagenauswahl +- Verkäufernotizen mit Zeichenzähler +- Vorlagen übernehmen Plattform, Sprache, Hinweise und Standard-Tags +- global hinterlegte Verkäuferhinweise, Vorlagenhinweise und Artikelhinweise werden kontrolliert zusammengeführt + +## Vorschau & Bearbeitung + +- permanenter, kontextbezogener Ergebnisbereich +- Live-Zähler für Titel, Beschreibung und Tags +- editierbare erkannte Attribute +- Kategorie-Picker, Preis und Lagerort +- dynamische Gebührenvorschau +- nachvollziehbarer Qualitätswert +- HTML- und Plattformvorschau als optional aufklappbarer Bereich +- Speichern und Kopieren ohne Fensterwechsel + +## API-Erweiterung + +`POST /api/generate` akzeptiert zusätzlich: + +```json +{ + "template_id": 1, + "seller_notes": "Artikelspezifische Hinweise" +} +``` + +Standard-Tags der gewählten Vorlage werden dedupliziert mit den AI-Tags zusammengeführt. + +## Qualitätsregeln + +- keine neuen Frameworks +- keine neuen Datenbankspalten +- bestehende IDs und Generatorfunktionen bleiben kompatibel +- alle Farben und Zustände nutzen die Vendoo-CSS-Variablen +- Dark Mode und responsive Darstellung sind Bestandteil des Slices diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_DEPLOYMENT_1_34_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_DEPLOYMENT_1_34_0.md new file mode 100644 index 0000000..0bd1863 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_DEPLOYMENT_1_34_0.md @@ -0,0 +1,76 @@ +# Vendoo 1.34.0 – GitHub Deployment + +## Repository + +- Repository: `Masterluke77/vendoo` +- Sichtbarkeit: privat +- Stabiler Branch: `main` +- Entwicklungsbranch: `develop` +- Erstimport-Branch: `release/1.34.0-initial-import` + +## Kontrollierter Erstimport + +Unter Windows wird `GitHub-Deploy-Assistent.bat` gestartet. Der Assistent: + +1. prüft beziehungsweise installiert Git für Windows über `winget`, +2. klont die vorhandene GitHub-Baseline in einen temporären Arbeitsordner, +3. verwendet oder erstellt `release/1.34.0-initial-import`, +4. kopiert nur den bereinigten Vendoo-Quellstand, +5. blockiert `.env`, Datenbanken, Uploads, Backups, Logs, Laufzeitdaten und Modelle, +6. führt nach Möglichkeit `tools/verify-git-safety.mjs` aus, +7. zeigt alle Änderungen vor dem Commit, +8. verlangt zur Freigabe die exakte Eingabe `PUSH`, +9. pusht ausschließlich den Release-Branch, +10. öffnet den vorbereiteten Pull-Request-Vergleich im Browser. + +Der Assistent löscht oder verändert keine produktiven Vendoo-Daten. Der temporäre Git-Arbeitsordner wird nach Abschluss entfernt. + +## Pull Request und Merge + +Der Pull Request wird gegen `main` erstellt. Vor dem Merge müssen die GitHub-Actions-Prüfungen erfolgreich sein: + +- reproduzierbare Installation über `npm ci`, +- Git-Sicherheitsgate, +- JavaScript-/MJS-Syntax, +- Deployment-Gate 1.34.0, +- Datenbankmigrationstest, +- Compose-Prüfung für lokal, NAS und VPS, +- Docker-Testbuild. + +Empfohlen ist ein Squash-Merge. Erst danach wird ein GitHub Release `v1.34.0` veröffentlicht. + +## Release-Automation + +Beim Veröffentlichen eines GitHub Releases: + +- wird ein bereinigtes Release-Staging erzeugt, +- werden Einzeldatei-SHA-256-Prüfsummen erzeugt, +- wird `Vendoo_1.34.0.zip` gebaut, +- wird eine SHA-256-Datei für das ZIP erzeugt, +- werden beide Dateien an das GitHub Release angehängt, +- wird ein Multi-Arch-Container für `linux/amd64` und `linux/arm64` gebaut, +- wird das Image als `ghcr.io/masterluke77/vendoo` in die GitHub Container Registry veröffentlicht, +- wird der veröffentlichte Image-Digest mit einer GitHub-Provenienz-Attestierung versehen. + +Das Release-ZIP wird nicht dauerhaft in die Git-Historie committed. + +## Secrets + +Produktive Secrets gehören ausschließlich in: + +- die lokale `.env`, die der Installer automatisch erzeugt, +- den persistenten Runtime-Konfigurationsspeicher von Vendoo, +- gegebenenfalls geschützte GitHub Environments für spätere Deployments. + +Nicht zulässig sind Secrets in Quellcode, Issues, Pull Requests, Workflow-Ausgaben oder Release-Paketen. + +## Späteres Produktionsdeployment + +Ein automatisches Deployment auf NAS oder VPS wird erst freigeschaltet, wenn diese Gates praktisch bestätigt sind: + +- Backup und Restore auf dem Zielsystem, +- Readiness nach Update, +- Rollback auf die vorherige Container-Version, +- TLS und Domainprüfung, +- getrennte Staging- und Produktionsumgebung, +- manuelle Freigabe des GitHub Environments `production`. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_IMPORT_STATUS.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_IMPORT_STATUS.md new file mode 100644 index 0000000..e9cb68d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/GITHUB_IMPORT_STATUS.md @@ -0,0 +1,10 @@ +# Vendoo GitHub-Importstatus + +- Zielrelease: `1.36.0` +- Importbranch: `release/1.36.0-theme-manager` +- Zielbranch: `main` +- Draft Pull Request: `#9` + +Der vollständige Quellstand wird über `GitHub-Deploy-Assistent.bat` beziehungsweise `scripts/github-deploy.sh` übertragen. Vor dem Merge müssen Git-Sicherheitsgate, Theme-Manager-Gate, Architektur-Gate, Datenbankmigrationen und GitHub Actions erfolgreich sein. + +Nicht nach GitHub gelangen dürfen `.env`, Datenbanken, Uploads, Backups, Logs, Operations-/Update-Artefakte oder FLUX-/ComfyUI-Modelle. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md new file mode 100644 index 0000000..0883103 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md @@ -0,0 +1,20 @@ +# Vendoo 1.23.1 – Editor-/Vinted-Hotfix + +## Behobene Fehler + +1. Der Advanced Image Editor war nur über ein kleines Stift-Symbol erreichbar und konnte bei alten Browser-Assets scheinbar vollständig fehlen. +2. Die Vinted-Auswahl für Zustand, Marke, Größe oder Farbe konnte durch einen zu breiten DOM-Fallback das Titelfeld als erstes Eingabefeld auswählen und überschreiben. + +## Umsetzung + +- Sichtbarer Button **Advanced Editor öffnen** im Generator. +- Versionsparameter für `app.js` und `style.css`. +- `Cache-Control: no-store` für HTML, JavaScript und CSS. +- Build-Kennung 1.23.1 im HTML und beim Browserstart. +- Strikte, labelgebundene Feldauflösung in allen Chrome-, Edge- und Firefox-Paketen. +- Zusätzlicher Titelfeldschutz für Choice-Felder. +- Extension-Version 1.6.1. + +## Nutzeraktion nach Installation + +Die Browser-Erweiterung muss in der Erweiterungsverwaltung einmal über **Neu laden** aktualisiert werden. Danach die Vinted-Verkaufsseite vollständig neu laden. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md new file mode 100644 index 0000000..b417a4f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md @@ -0,0 +1,23 @@ +# Vendoo 1.23.2 – FLUX-/Editor-Hotfix + +## FLUX Studio + +- `System & ComfyUI` befindet sich unterhalb von `Prompt & Auftrag`. +- Die angeheftete Bildvorschau enthält keine darunterliegende Systemkarte mehr und kann dadurch die Bildaktionen nicht überdecken. +- Das komplette Job-Center kann ein- oder ausgeklappt werden. +- Jeder Auftrag besitzt eine kompakte Kopfzeile und einen einzeln ein-/ausklappbaren Detailbereich. +- Aktive Aufträge werden beim ersten Laden geöffnet; terminale Aufträge bleiben kompakt. + +## Advanced Image Editor + +- Hintergrundentfernung ist als Editor-Operation integriert. +- Wasserzeichen unterstützt Text, Deckkraft sowie oben/unten links/rechts und Mitte. +- Beide Werkzeuge werden nicht destruktiv als neue Version gespeichert. +- Die bisherigen separaten Buttons wurden entfernt. +- Die Werkzeuge funktionieren in Generator, Listing-Editor und FLUX Studio. + +## Unveränderte Schutzregeln + +- Kein VTO/FASHN. +- Keine produktiven Daten oder Modelle im Paket. +- Vendoo Link bleibt 1.6.1; der Vinted-Feldschutz wird nicht zurückgenommen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_STUDIO_FLOW_DETAIL.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_STUDIO_FLOW_DETAIL.md new file mode 100644 index 0000000..6194ec5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/HOTFIX_STUDIO_FLOW_DETAIL.md @@ -0,0 +1,16 @@ +# Hotfix – Studio Flow Detailnavigation + +## Fehler + +Der Button **Prüfen & bearbeiten** lud die Listing-Detailansicht, ohne zuvor den Listings-Tab sichtbar zu schalten. Der Editor wurde dadurch in einem ausgeblendeten Bereich aufgebaut. + +## Korrektur + +`showDetail(id)` prüft nun zentral, ob der Listings-/Historie-Tab aktiv ist. Falls nicht, wird dieser vor dem Laden des Listings aktiviert. Damit funktionieren Detailaufrufe aus: + +- Studio Flow +- Attention Queue +- Listings-Tabelle +- weiteren internen Bereichen + +Zusätzlich werden ungültige Listing-IDs abgefangen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md new file mode 100644 index 0000000..fa951a0 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md @@ -0,0 +1,134 @@ +# Vendoo 1.32.0 – Installation, Update und Prüfung + +## A. Windows-Update von 1.31.0 + +1. Vendoo sowie offene Setup-, Node- und FLUX-Fenster schließen. +2. Das Release-ZIP vollständig in einen temporären Ordner entpacken. +3. Im entpackten Paket den Ordner `vendoo` öffnen. +4. Den **Inhalt** dieses Ordners nach `W:\ChatGPT\Vendoo` kopieren. +5. Windows-Abfrage **Dateien im Ziel ersetzen** bestätigen. +6. `W:\ChatGPT\Vendoo` vorher **nicht löschen, leeren oder umbenennen**. +7. `.env`, `db\vendoo.db`, `uploads`, `backups`, `operations`, lokale FLUX-Modelle und ComfyUI-Daten nicht löschen. +8. `W:\ChatGPT\Vendoo\setup.bat` starten. +9. Menüpunkt **3 – Update** ausführen. +10. Danach Menüpunkt **2 – Starten** ausführen. +11. `http://127.0.0.1:8124/healthz` und `http://127.0.0.1:8124/readyz` prüfen. + +Windows benötigt die neuen Pfadvariablen nicht. Ohne Eintrag bleiben DB, Uploads und Backups exakt an ihren bisherigen Orten. + +## B. Docker-Neuinstallation + +1. Release entpacken und in den gewünschten Server-/NAS-Ordner wechseln. +2. `.env.example` als `.env` kopieren. +3. Einen starken Einrichtungs-Code erzeugen, mindestens 32 zufällige Zeichen, und als `VENDOO_SETUP_TOKEN` setzen. Beispiele: + +```bash +openssl rand -hex 32 +``` + +PowerShell: + +```powershell +[Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLower() +``` + +Die `.env` anschließend nur für den Administrator lesbar machen (`chmod 600 .env` auf Linux/NAS). +4. Für lokalen Test `PUBLIC_BASE_URL` zunächst leer lassen. Für Produktion die echte HTTPS-URL und Host-/Origin-Allowlist setzen. +5. Starten: + +```bash +docker compose config +docker compose build --pull +docker compose up -d +``` + +6. Zustand prüfen: + +```bash +docker compose ps +docker compose logs --tail=200 vendoo +``` + +7. Lokal `http://127.0.0.1:8124/readyz` öffnen. Erwartet wird HTTP 200 und `"ok": true`. +8. Vendoo öffnen, ersten Administrator anlegen und dabei den `VENDOO_SETUP_TOKEN` als Einrichtungs-Code eingeben. +9. Danach Reverse Proxy und HTTPS konfigurieren. Port 8124 nicht direkt aus dem Internet veröffentlichen. +10. Testbackup erstellen und Prüfsumme kontrollieren. + +## C. Portainer/NAS + +### Empfohlen: Named Volumes + +`compose.yaml` direkt als Stack verwenden. Portainer legt fünf persistente Volumes an. Das Image läuft ohne Rootrechte. + +### Option: sichtbare NAS-Ordner + +1. Einen dedizierten Zielordner verwenden, zum Beispiel `/volume1/docker/vendoo`, und darunter anlegen: + +```text +runtime/db +runtime/uploads +runtime/backups +runtime/operations +runtime/logs +``` + +2. Diese Ordner UID/GID 1000 schreibbar machen. Den Vendoo-Container nicht als Root starten. +3. Compose mit Override starten: + +```bash +docker compose -f compose.yaml -f compose.bind-mounts.example.yaml up -d --build +``` + +Bei `Kein Schreibzugriff ... (Container-UID 1000)` keine Root-Ausführung aktivieren, sondern die Ordnerrechte korrigieren. `LOCAL_IMAGE_ENABLED` bleibt im Hauptcontainer standardmäßig `false`; ein vorhandenes FLUX/ComfyUI wird separat betrieben und über `LOCAL_IMAGE_URL` angebunden. + +## D. Docker-Update + +```bash +./scripts/docker-backup.sh +# neuen Vendoo-Code über den alten Codeordner kopieren; .env nicht ersetzen +docker compose config +docker compose build --pull +docker compose up -d --force-recreate +docker compose ps +docker compose logs --tail=200 vendoo +``` + +Anschließend `/readyz`, Login, Artikelübersicht, ein vorhandenes Bild, Speichern eines Testartikels, SmartCopy und Publish-Center ohne echte Veröffentlichung prüfen. + +### Rollback + +1. Container stoppen. +2. Vorheriges Image beziehungsweise vorherigen Code wieder verwenden. +3. Volumes nicht löschen. +4. Container starten und Readiness prüfen. +5. Nur wenn eine Datenmigration das verlangt, das zuvor erzeugte gemeinsame DB-/Upload-Backup in einen sicheren Testpfad zurückspielen und erst danach produktiv restaurieren. + +## E. Backup und Restore + +Backup mit kurzem geordnetem Stopp: + +```bash +./scripts/docker-backup.sh +``` + +Restore ersetzt die aktuell gemounteten DB-, Upload-, Backup-, Operations- und Logdaten. Das Skript prüft eine vorhandene SHA-256-Datei und erstellt vorab automatisch ein zusätzliches Sicherheitsbackup: + +```bash +./scripts/docker-restore.sh /pfad/zum/vendoo-data.tar.gz --confirm +``` + +Danach zwingend `/readyz`, Login, Benutzer, Artikel, Bilder und Backup-Liste prüfen. + +## F. Funktionsprüfung + +- Login, Logout und erneute Anmeldung +- Rollen-/Adminschutz +- Artikel laden, erstellen, bearbeiten und speichern +- Bild hochladen und erneut abrufen +- Galerie-Viewer und Zoom +- SmartCopy öffnen und Inhalte kopieren +- Publish-Center laden, ohne echte Veröffentlichung auszulösen +- `/healthz` und `/readyz` +- Containerneustart; Artikel und Bilder müssen erhalten bleiben +- Backup erzeugen und SHA-256 prüfen +- FLUX-Ausfall: Vendoo muss weiter starten und Kernfunktionen bereitstellen diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md new file mode 100644 index 0000000..52c005f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md @@ -0,0 +1,105 @@ +# Vendoo 1.33.0 – Installation und Abnahme + +## 1. Bestehende Windows-Installation aktualisieren + +1. Vendoo und FLUX schließen. +2. Release entpacken. +3. Den Inhalt von `vendoo` über den vorhandenen Projektordner kopieren. +4. Vorhandene Dateien ersetzen, den Zielordner nicht vorher leeren. +5. `.env`, Datenbank, Uploads, Backups, Operations, lokale Modelle und ComfyUI-Daten nicht löschen. +6. `setup.bat` starten. +7. **3 – Update** ausführen. +8. **2 – Starten** ausführen. + +## 2. Neue Windows-Installation + +`setup.bat` starten und unter **1 – Installieren / Installationsziel wählen** auswählen: + +- **Windows lokal:** Node.js, npm und lokale Projektordner +- **Docker Desktop:** Compose-Build, getrennte Volumes und automatischer Setup-Code +- **NAS/Portainer:** Übergabe an den Linux-/NAS-Assistenten +- **VPS/Linux:** Installation direkt auf dem Zielserver + +Der gewählte aktive Modus wird in `.vendoo-install-mode` gespeichert. Diese Datei enthält keine Geheimnisse. + +## 3. Linux, NAS oder VPS + +```bash +chmod +x setup.sh +./setup.sh +``` + +Auswahl: + +1. Docker auf diesem Linux-/Docker-Host mit Named Volumes +2. NAS/Portainer mit sichtbaren `runtime`-Bind-Mounts +3. VPS hinter HTTPS-Reverse-Proxy + +Der Assistent prüft Docker und das Compose-Plugin, erzeugt bei Bedarf `.env` sowie einen starken `VENDOO_SETUP_TOKEN`, validiert Compose und startet Vendoo. + +### NAS + +Der NAS-Modus legt folgende Ordner an: + +```text +runtime/db +runtime/uploads +runtime/backups +runtime/operations +runtime/logs +``` + +Port 8124 wird im LAN veröffentlicht. Keine direkte Internet-Portfreigabe verwenden. + +### VPS + +- echte öffentliche `https://`-URL angeben +- DNS und TLS vorab oder anschließend korrekt konfigurieren +- Reverse Proxy auf `127.0.0.1:8124` weiterleiten +- Port 8124 nicht öffentlich freigeben +- `VENDOO_TRUST_PROXY=true` nur hinter dem eigenen Proxy verwenden + +## 4. FLUX im Docker-Betrieb + +FLUX bleibt ein getrennter Dienst. Im Docker-Container wird ein Host-Dienst über folgende URL erreicht: + +```env +LOCAL_IMAGE_URL=http://host.docker.internal:8188 +``` + +`LOCAL_IMAGE_ENABLED` nur aktivieren, wenn FLUX/ComfyUI tatsächlich separat läuft und abgesichert ist. + +## 5. Abnahme + +### Deployment + +- `/healthz` liefert HTTP 200 +- `/readyz` liefert HTTP 200 und `ok: true` +- Neustart erhält Benutzer, Artikel und Bilder +- Backup wird erzeugt und hat SHA-256 +- Docker-Status zeigt `healthy` + +### FLUX Studio + +- Seite öffnen: Vorschau **Auswahl & Varianten** bleibt leer +- Historie ist sichtbar, aber kein Bild ist automatisch markiert +- Historienbild anklicken: Vorschau erscheint +- **Arbeitsbereich zurücksetzen**: Auswahl und Formular werden geleert/zurückgesetzt +- Bilder, Verlauf und Job-Center bleiben erhalten + +### Benutzerverwaltung + +- Benutzerliste laden +- Bearbeiten-Button öffnet das richtige Benutzerkonto +- Name, Rolle und Status speichern +- ungültiges Passwort verhindert die gesamte Speicherung +- gültiges Passwort wird gesetzt +- letzter aktiver Administrator kann nicht deaktiviert oder herabgestuft werden + +## 6. Rollback + +1. Vorheriges Vendoo-Paket über den Code kopieren. +2. `.env`, DB, Uploads und Volumes nicht ersetzen oder löschen. +3. Windows: `setup.bat` → Update → Starten. +4. Docker: `./setup.sh` → Aktualisieren oder vorheriges Image/Code bauen. +5. Readiness und Datenbestand prüfen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_34_0_ZERO_EDIT.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_34_0_ZERO_EDIT.md new file mode 100644 index 0000000..b374891 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/INSTALLATION_1_34_0_ZERO_EDIT.md @@ -0,0 +1,112 @@ +# Vendoo 1.34.0 – Zero-Edit-Installation + +## Grundsatz + +Für keine unterstützte Betriebsart muss eine `.env`, Compose-Datei oder Proxy-Konfiguration manuell bearbeitet werden. Der Installer sammelt nur notwendige Angaben, validiert sie und schreibt die Konfiguration automatisch. + +AI-, SMTP- und Marktplatz-Zugangsdaten werden nach dem ersten Login unter **Vendoo → Einstellungen** gepflegt. Im Docker-/NAS-/VPS-Betrieb werden diese Werte in einem persistenten Konfigurationsvolume gespeichert und nach Neustarts wieder geladen. + +## Windows lokal + +1. `setup.bat` doppelklicken. +2. **Lokal auf diesem Windows-PC** wählen. +3. Erreichbarkeit festlegen: + - nur dieser PC, + - privates LAN. +4. Port bestätigen oder ändern. +5. optional eine LAN-Basisadresse für QR-Uploads eingeben. +6. optional FLUX aktivieren. +7. **Installieren** anklicken. +8. Erst-Admin-Code aus der Zwischenablage bei der ersten Einrichtung einfügen. + +Node.js wird geprüft. Fehlt Node.js, bietet der bestehende Installer eine automatische Installation an. + +## Windows mit Docker Desktop + +1. Docker Desktop starten. +2. `setup.bat` doppelklicken. +3. **Docker Desktop auf diesem Windows-PC** wählen. +4. Zugriff nur lokal oder im privaten LAN festlegen. +5. Port und optional QR-Basisadresse festlegen. +6. **Installieren** anklicken. + +Der Assistent erzeugt `.env`, Setup-Code und Installationsmodus automatisch. Die App bleibt standardmäßig nur auf `127.0.0.1` erreichbar. + +## Linux oder macOS mit Docker + +```sh +chmod +x setup.sh +./setup.sh +``` + +Im Menü **Docker auf Linux/macOS/Docker Desktop** wählen. Der Assistent fragt Port und FLUX-Konfiguration ab und startet anschließend Compose. + +## NAS / Portainer + +1. Vendoo-Ordner auf das NAS kopieren. +2. im Ordner `chmod +x setup.sh && ./setup.sh` ausführen. +3. **NAS / Portainer / privates LAN** wählen. +4. Port und LAN-Basisadresse eingeben. +5. Installation starten. + +Der Assistent legt automatisch folgende sichtbare Runtime-Verzeichnisse an: + +- `runtime/db` +- `runtime/uploads` +- `runtime/backups` +- `runtime/operations` +- `runtime/logs` +- `runtime/config` + +Diese Verzeichnisse werden bei Updates nicht gelöscht. + +## VPS mit automatischem HTTPS + +Voraussetzungen: + +- Domain zeigt per DNS auf den VPS, +- Ports 80 und 443 sind erreichbar, +- Docker Engine und Compose-Plugin sind installiert. + +Ablauf: + +```sh +chmod +x setup.sh +./setup.sh +``` + +Dann **VPS / öffentlicher Server mit automatischem HTTPS** wählen und den Domainnamen ohne Protokoll eingeben. Der integrierte Caddy-Stack beantragt und erneuert TLS-Zertifikate automatisch. Vendoo selbst veröffentlicht Port 8124 nicht direkt ins Internet. + +## Persistenter Konfigurationsspeicher + +Im lokalen Windows-Betrieb verwendet Vendoo weiterhin die lokale `.env`. In Docker setzt der Installer automatisch: + +```text +VENDOO_CONFIG_PATH=/app/data/config/vendoo.env +``` + +Die Eingabemasken schreiben dort ihre Werte mit restriktiven Dateirechten. Das Haupt-Dateisystem des Containers bleibt schreibgeschützt. + +## Backup und sensible Konfiguration + +Normale Docker-Backups enthalten Datenbank, Uploads, Backups, Operations und Logs. Der persistente Runtime-Konfigurationsspeicher mit API-/SMTP-/Marktplatzschlüsseln wird bewusst nicht in das normale Datenarchiv aufgenommen, damit ein weitergegebenes Backup nicht automatisch alle Zugangsdaten enthält. + +Bei einer Wiederherstellung auf einem neuen System werden Betriebswerte durch den Installationsassistenten neu erzeugt und externe Zugangsdaten anschließend über **Vendoo → Einstellungen** erneut eingetragen. Dazu muss keine Datei manuell bearbeitet werden. + +## Updates + +Im Linux-/NAS-/VPS-Assistenten erstellt **Aktualisieren** zuerst automatisch ein Sicherheitsbackup. Danach werden Image beziehungsweise Quellcontainer neu gebaut, gestartet und über `/readyz` geprüft. + +Unter Windows bleibt der bestehende Update- und Rollback-Ablauf erhalten. + +## Nicht automatisch verändert + +- Datenbank +- Uploads und Originalbilder +- Benutzer +- Artikel +- Backups +- Operations-Daten +- FLUX-Modelle +- ComfyUI-Daten +- API-Schlüssel diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/LISTINGS_UI_2026.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/LISTINGS_UI_2026.md new file mode 100644 index 0000000..8d3528c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/LISTINGS_UI_2026.md @@ -0,0 +1,31 @@ +# Vendoo Listings UI 2026 + +## Ziel + +Die Listings-/Historie-Seite bildet die operative Übersicht aller Artikel. Sie folgt der freigegebenen Vendoo-Designsprache und ersetzt die alte Kartenliste durch eine dichte, bildorientierte Arbeitsliste. + +## Funktionsumfang + +- Volltextsuche über bestehende Listing-Suche +- Plattform- und Statusfilter +- Zeiträume 7, 30 und 90 Tage, aktuelles Jahr, gesamter Zeitraum und benutzerdefinierte Daten +- Spaltenkonfiguration mit Speicherung im Browser +- Seitengrößen 10, 20, 50 oder alle +- Seitennavigation mit direkten Seitenschaltflächen +- Auswahl einzelner Listings und Auswahl der aktuellen Seite +- Batch Publish, Nachbearbeiten, Preisänderung, Statusänderung, Duplizieren, Etikettendruck und Löschen +- CSV- und JSON-Export +- Direktänderung des Status pro Tabellenzeile +- Zeilenaktionen Öffnen, Duplizieren und Löschen +- Publish-Status-Badges je Plattform + +## Technische Regeln + +Die Umsetzung bleibt vollständig in der bestehenden Vanilla-Architektur: + +- Markup: `public/index.html` +- Verhalten: `public/app.js` +- Design: `public/style.css` +- keine neuen Frameworks +- keine Änderungen am Datenbankschema erforderlich +- vorhandene API-Endpunkte und CSRF-Absicherung werden weiterverwendet diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/PUBLISH_CENTER_2026.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/PUBLISH_CENTER_2026.md new file mode 100644 index 0000000..10dece3 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/PUBLISH_CENTER_2026.md @@ -0,0 +1,28 @@ +# Vendoo Publish-Center 2026 + +## Ziel + +Das Publish-Center bündelt alle Arbeitsschritte zwischen fertigem Listing und Veröffentlichung in einer einzigen, kontextstabilen Oberfläche. + +## Bereiche + +- **Smart Copy:** Felder, HTML/Text, Fotos und Plattformwechsel +- **Direkt veröffentlichen:** Etsy- und eBay-API mit vorheriger Datenprüfung +- **eBay Queue:** Status, Fehler, Wiederholungen und Verarbeitung +- **Geplant:** zeitgesteuerte Veröffentlichungen mit Plattform und Zeitpunkt + +## UX-Regeln + +- keine internen Arbeitsabläufe in neuen Browserfenstern +- Produktbild, Titel und SKU bleiben im Kontext sichtbar +- Fehler und fehlende Pflichtfelder sind direkt handlungsfähig +- externe Plattformen öffnen nur dann einen neuen Tab, wenn die Plattform selbst benötigt wird +- alle Statusfarben verwenden die zentralen Vendoo-Tokens + +## Technische Grundlage + +- Vanilla JavaScript in `public/app.js` +- Markup in `public/index.html` +- Design-Tokens und responsive Komponenten in `public/style.css` +- bestehende Endpunkte für Smart Copy, Etsy, eBay Queue und Scheduling +- Session-, Rollen- und CSRF-Schutz bleiben unverändert aktiv diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md new file mode 100644 index 0000000..6243566 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md @@ -0,0 +1,50 @@ +# Vendoo 1.24.0 – Responsive UI, FLUX-Auftragsarchiv und Bildergalerie + +## Ziel + +Version 1.24.0 schließt konkrete Bedienungs- und Verwaltungsprobleme zwischen Slice 31 und dem späteren Batch-Image-Slice. Der Release verändert keine bestehende FLUX-Modellinstallation und führt keine Virtual-Try-on-Funktion ein. + +## Responsive Härtung + +Die bisherige Listing-Tabelle erzwang auf kleineren Ansichten eine Mindestbreite von bis zu 1080 Pixeln. Unterhalb von 860 Pixeln wird sie nun als Kartenliste dargestellt. Plattform, Provider, Preis, Status, Lagerort und Aktualisierung bleiben als beschriftete Zeilen lesbar. Benutzerdefiniert ausgeblendete Spalten bleiben auch mobil ausgeblendet. + +Zusätzlich wurden Breiten, Umbrüche und Overflow-Verhalten für Filterleisten, Batch-Aktionen, Pagination, FLUX-Job-Center, Galerie, Formulare und zentrale Arbeitsbereiche abgesichert. + +## Hintergrundentfernung + +`background_sensitivity` wird als Wert von 0 bis 100 in den nicht destruktiven Bearbeitungsparametern gespeichert. Vorschau und Sharp-Rendering verwenden dieselbe dynamische Schwellwert- und Feather-Logik. + +- niedriger Wert: konservative Entfernung, mehr Kantenschutz +- hoher Wert: aggressivere Entfernung ähnlicher Hintergrundfarben + +## FLUX-Auftragsarchiv + +Die additive Spalte `flux_prompt_jobs.archived_at` trennt aktive Historie und Archiv. Nur terminale Aufträge können archiviert werden. Laufende, wartende oder abbrechende Aufträge werden serverseitig übersprungen. + +Funktionen: + +- einzeln oder gesammelt archivieren +- einzeln oder gesammelt wiederherstellen +- einzelne oder mehrere archivierte Aufträge löschen +- gesamtes Archiv löschen +- serverseitige Pagination und Statusfilter +- erzeugte Bilder bleiben beim Löschen von Auftragsmetadaten erhalten + +## Bildergalerie + +Der neue Navigationsbereich „Bilder“ aggregiert vorhandene Dateien aus: + +- `flux_images` +- `image_edit_versions` +- `ai_model_photos` +- den Fotoverweisen aktiver Listings + +Doppelte Dateipfade werden zusammengeführt und mit mehreren Quellen-Badges dargestellt. Die Galerie bietet Raster-/Listenansicht, Suche, Quellenfilter, Favoriten, Sortierung, Pagination, Mehrfachauswahl, ZIP-Download, Lightbox, Editor-Übergabe und Generator-Übernahme. Dateien aktiver Listings werden beim Löschen geschützt. + +## Nicht enthalten + +- keine FASHN- oder VTO-Engine +- keine Änderung an FLUX-Modellen +- keine Datenbank-, Upload-, Listing- oder Schlüsselbereinigung +- keine Hintergrundjobs außerhalb des bestehenden Vendoo-Prozesses +- keine reale Windows-, GPU- oder ComfyUI-Abnahme ohne Nutzerausgaben diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_VERIFICATION.json new file mode 100644 index 0000000..89ecaaf --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_24_0_VERIFICATION.json @@ -0,0 +1,84 @@ +{ + "release": "1.24.0", + "date": "2026-07-08", + "checks": { + "javascript_and_esm_syntax": { + "status": "passed", + "files": 51 + }, + "json_validation": { + "status": "passed", + "files": 18 + }, + "html_ids": { + "status": "passed", + "total": 592, + "unique": 592, + "duplicates": 0 + }, + "css_structure": { + "status": "passed", + "opening_braces": 2471, + "closing_braces": 2471 + }, + "responsive_listing_rules": { + "status": "passed", + "mobile_card_layout": true, + "fixed_minimum_width_removed_below_1180": true, + "hidden_column_preferences_preserved": true + }, + "background_removal_sensitivity": { + "status": "passed", + "low_sensitivity_transparent_pixels": 60256, + "high_sensitivity_transparent_pixels": 80660, + "higher_value_removes_more": true + }, + "database_migration": { + "status": "passed", + "additive_column": "flux_prompt_jobs.archived_at", + "existing_flux_image_preserved": true + }, + "flux_archive_semantics": { + "status": "passed", + "terminal_jobs_archivable": true, + "active_jobs_protected": true, + "restore": true, + "single_delete": true, + "multi_delete": true, + "delete_all": true, + "generated_images_retained_by_design": true + }, + "media_library": { + "status": "passed", + "sources": ["flux", "edited", "ai", "listing"], + "search": true, + "filters": true, + "favorites": true, + "grid_and_list": true, + "pagination": true, + "multi_select": true, + "zip_download": true, + "lightbox": true, + "editor_handoff": true, + "listing_image_delete_protection": true + }, + "vinted_extension_regression_guard": { + "status": "passed", + "variants": 5, + "title_field_protection_present": true, + "extension_version_changed": false + }, + "package_sources": { + "status": "passed", + "registry": "https://registry.npmjs.org/", + "internal_openai_or_artifactory_sources": false + } + }, + "not_claimed": [ + "real Windows PowerShell 5.1 acceptance", + "real browser interaction on the user's installation", + "real NVIDIA GPU acceptance", + "real ComfyUI queue/history acceptance", + "real mapped-drive W: acceptance" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md new file mode 100644 index 0000000..82ffc2a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md @@ -0,0 +1,59 @@ +# Vendoo 1.25.0 – Responsive Detail Editor, Gallery UX, Smart Copy und FLUX Prompt Lab + +## Ziel + +Version 1.25.0 korrigiert die verbliebenen Breitenprobleme in der Listing-Bearbeitung und überarbeitet mehrere Bedienwege, die im produktiven Einsatz unruhig, zu eng oder nicht mehr erreichbar waren. Der Release baut auf 1.24.0 auf und bleibt vollständig als Drüberkopier-Update installierbar. + +## Listing-Bearbeitung + +- Der Detail-Editor reagiert per Container Query auf die tatsächlich verfügbare Inhaltsbreite und nicht nur auf die gesamte Browserbreite. +- Fotoleiste, Listing-Daten, Beschreibung, Plattformvorschau und Kontextkarten reduzieren sich kontrolliert von drei auf zwei und schließlich eine Spalte. +- Formularfelder, Quill-Werkzeugleiste, Iframes, Textbereiche, Aktionsleisten und lange Inhalte besitzen konsequente `min-width: 0`- und Umbruchregeln. +- Zusätzliche Media-Query-Fallbacks halten den Editor auch in Browsern ohne aktive Container-Query-Auswertung bedienbar. + +## Bildergalerie + +- Auswahlfelder sind als präzise eigene Checkbox-Komponente umgesetzt und liegen sauber innerhalb der Bildfläche. +- Favoriten befinden sich als separates Overlay oben rechts. +- Die große Vorschauaktion liegt als eindeutige, zentrierte Leiste unten im Bild und kollidiert nicht mit Auswahl oder Favorit. +- Titel können über zwei Zeilen laufen; Metadaten werden sauber gekürzt. +- Bearbeiten, Verwenden, Download und Löschen stehen in einem stabilen 2×2-Aktionsraster. +- Raster- und Listenansicht sowie mobile Ein- und Zweispaltenansichten bleiben erhalten. + +## Publish und Smart Copy + +- Aktualisieren verwendet einen ruhigen Ladezustand mit Text und `aria-busy`; der gesamte Button rotiert nicht mehr. +- Smart Copy öffnet wieder in einem eigenen responsiven Modal. +- Das Modal enthält Titel, Beschreibung, Tags, Metadaten, Fotos, Kopieraktionen, Plattformstart und Veröffentlichungsstatus. +- Vorheriges/nächstes Listing kann direkt im Modal gewechselt werden. +- Nach dem Markieren als veröffentlicht wird das Modal sauber geschlossen und der Publish-Bereich neu geladen. + +## FLUX Studio + +### Ergebnisse & Favoriten + +- Serverseitige Pagination mit 5, 10 oder 15 Bildern pro Seite. +- Vorherige-/Nächste-Navigation und aktuelle Seitenangabe. +- Favoritenfilter und Löschaktionen laden die korrekte Seite neu. + +### Job-Center + +- Aktive Aufträge und Archiv verwenden kompakte Seitengrößen von 5, 10 oder 15 Einträgen. +- Bestehende Archiv-, Wiederherstellungs-, Mehrfachauswahl- und Löschfunktionen bleiben erhalten. + +### Prompt Lab + +- Der bisherige einfache Prompt-Verbesserer öffnet ein eigenes Prompt Lab. +- Lokale Strukturierung funktioniert ohne API-Aufruf. +- AI-Veredelung nutzt den aktuell aktiven Text-Provider: Claude, OpenAI, OpenRouter oder Ollama. +- Profile: präzise, Produkt/Marketplace, kreativ und cinematisch. +- Optionaler Schutz der Motividentität sowie Vermeidung erfundener Texte, Logos und Wasserzeichen. +- Ergebnis, Provider, Modell und kurze AI-Hinweise werden sichtbar dargestellt. +- Der optimierte Prompt kann direkt zurück ins FLUX Studio übernommen werden. + +## Unveränderte Sperrregeln + +- Kein FASHN VTON und kein Virtual Try-on. +- FLUX bleibt ein freier Prompt-Bildgenerator. +- Keine Datenbank, Listings, Uploads, `.env`, Schlüssel, ComfyUI-Installation oder FLUX-Modelle werden entfernt. +- Keine internen Paketquellen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_VERIFICATION.json new file mode 100644 index 0000000..16c6ed1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_0_VERIFICATION.json @@ -0,0 +1,30 @@ +{ + "release": "1.25.0", + "static_checks": { + "javascript_esm_syntax_files": 51, + "json_files": 18, + "unique_html_ids": 612, + "duplicate_html_ids": 0, + "css_brace_depth": 0 + }, + "functional_mock_checks": { + "database_migrations": true, + "flux_slice30_mock": true, + "advanced_image_editor_render": true, + "background_sensitivity_low_vs_high": true, + "listing_editor_container_responsive": true, + "gallery_card_controls": true, + "smart_copy_modal": true, + "publish_refresh_without_whole_button_rotation": true, + "flux_history_page_sizes": [5, 10, 15], + "flux_job_page_sizes": [5, 10, 15], + "prompt_lab_providers": ["claude", "openai", "openrouter", "local"], + "vinted_title_guard_preserved": true + }, + "not_claimed_as_real_acceptance": [ + "Windows PowerShell 5.1 update on W drive", + "interactive browser acceptance with user data", + "real AI provider request", + "real NVIDIA GPU or ComfyUI render" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md new file mode 100644 index 0000000..05de476 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md @@ -0,0 +1,5 @@ +# Vendoo 1.25.1 – Adaptive Bildergalerie + +Dieser Hotfix ersetzt die bisherige Galerie-Kartenkomponente vollständig. Das Raster berechnet Kartenanzahl und Kartenbreite automatisch aus dem tatsächlich verfügbaren Platz. Die gesamte Bildfläche ist die Vorschauaktion. Auswahl und Favorit bleiben als kompakte, skalierende Overlays erhalten. Die vier Bildaktionen bilden ein responsives Aktionsdock. Die Seitennavigation ist oberhalb und unterhalb der Galerie vorhanden und erlaubt einen direkten Sprung zu jeder Seite. + +Die Umsetzung orientiert sich an etablierten Karten-, Auswahl- und Paginierungsmustern: Inhalt und Aktionen gehören zu einer klar scanbaren Karte, Auswahl wird durch Checkbox plus Oberflächenzustand angezeigt und Pagination gibt sowohl Seitengröße als auch direkte Navigation vor. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_VERIFICATION.json new file mode 100644 index 0000000..a1f9e91 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_25_1_VERIFICATION.json @@ -0,0 +1,34 @@ +{ + "release": "1.25.1", + "package_type": "full_overlay_update", + "checks": { + "javascript_syntax": "passed", + "json_validity": "passed", + "html_unique_ids": "passed", + "css_parse": "passed", + "gallery_static_release_gate": "passed", + "database_migrations": "passed", + "existing_database_row_preserved": "passed", + "flux_archive_semantics": "passed", + "runtime_data_excluded": "passed" + }, + "gallery": { + "adaptive_auto_fit_grid": true, + "card_container_queries": true, + "full_image_click_target": true, + "legacy_open_button_removed": true, + "adaptive_selection_overlay": true, + "adaptive_favorite_overlay": true, + "adaptive_action_dock": true, + "pagination_top_and_bottom": true, + "direct_page_jump": true, + "touch_behavior": true, + "keyboard_accessibility": true + }, + "not_claimed": [ + "real Windows update on W drive", + "interactive production browser acceptance", + "native npm regression suite in this build environment" + ], + "note": "The native npm regression suite could not be reinstalled in the build environment because external better-sqlite3 build artifacts were temporarily unreachable. This hotfix changes only frontend HTML, CSS and gallery JavaScript; syntax, static release gates and additive database migration tests passed." +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md new file mode 100644 index 0000000..aef818a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md @@ -0,0 +1,29 @@ +# Vendoo 1.26.0 – Artikel, Publish, Zoom-Galerie und Extension Center + +## Ziel + +Version 1.26.0 vereinheitlicht die Artikelbegriffe, repariert die Publish-/Smart-Copy-Wege, erweitert die Galerie um eine echte Zoom-Vorschau und führt eine zentrale Installation für Browser-Erweiterungen ein. + +## Funktionsumfang + +- **Neuer Artikel** direkt unter Dashboard; **Artikel** statt Listings +- **Artikel generieren** statt Listing generieren +- Publish lädt robuste Artikeldaten und verwendet im Direkt-Workspace den richtigen Container +- Smart Copy als Popup mit vollständiger zentraler Vorschau +- Galerie-Lightbox mit 10–400 %, Fit, 100 %, Mausrad und Drag-Panning +- lokale Lucide-basierte SVG-Icons statt uneinheitlicher Symbolzeichen +- aktualisierte Gebührenhinweise für private Verkäufer und Etsy-Shop-Kalkulation +- Extensions-Seite mit aktiver Browsererkennung, Ordneröffnung, Pfadkopie und ZIP-Download +- Safari Web Extension als Quelle mit Xcode-Konvertierungsskript + +## Schutzregeln + +Das Update enthält keine produktive `.env`, Datenbank, Listings, Uploads, API-Schlüssel, Logs, FLUX-Modelle oder `node_modules`. Bestehende Nutzerdaten werden nicht gelöscht. + +## Noch real zu prüfen + +- Installation und Browsercache auf dem Windows-Zielsystem +- Publish mit den produktiven Artikeln +- Smart Copy in den realen Plattformformularen +- finale Browserbestätigung der Extensions +- Safari-Konvertierung und Signierung auf einem Mac mit Xcode diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_VERIFICATION.json new file mode 100644 index 0000000..d59f9fc --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_0_VERIFICATION.json @@ -0,0 +1,36 @@ +{ + "release": "1.26.0", + "date": "2026-07-08", + "status": "static-gates-passed", + "scope": [ + "article-navigation", + "publish-hardening", + "smart-copy-modal", + "media-zoom", + "fee-model", + "extension-center", + "safari-source" + ], + "real_acceptance_pending": [ + "Windows update", + "productive Publish data", + "browser extension confirmation", + "Safari Xcode conversion" + ], + "javascript_esm_files": 59, + "json_files": 22, + "html_ids": 621, + "duplicate_html_ids": 0, + "css_rule_blocks": 2792, + "extension_packages_verified": 4, + "database_migration_blocks": 43, + "existing_database_row_preserved": true, + "fee_test_price_eur": 100, + "fee_results": { + "ebay_private": 0, + "vinted_seller": 0, + "kleinanzeigen_private": 0, + "etsy_estimate": 10.8 + }, + "native_runtime_test": "not_run_dependency_download_unavailable" +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_STABILITY_GATE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_STABILITY_GATE.md new file mode 100644 index 0000000..dd5f2fe --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_STABILITY_GATE.md @@ -0,0 +1,22 @@ +# Vendoo 1.26.1 – Stabilitäts-, Responsive- und Regression-Gate + +## Ziel + +Der Release friert den Funktionsstand 1.26.0 ein und schafft eine belastbare Grundlage für Batch Image Factory, Artikel Quality Center und Publishing Hardening. Es werden keine produktiven Daten oder bestehenden Workflows entfernt. + +## Produktive Änderungen + +- `GET /healthz` liefert ausschließlich Version, Laufzeit und Zeitstempel. +- `GET /api/admin/diagnostics` prüft als Admin Datenbank, Dateisystem, Pflichtassets, Extensions, AI-Konfiguration und optional ComfyUI. +- Der Browser-API-Client nutzt Request-IDs, Zeitlimits und einen begrenzten GET-Retry. +- Netzwerkfehler werden sichtbar angezeigt und automatisch erneut geprüft. +- Die letzten 30 Clientfehler werden lokal im Browser gespeichert und können mit der Systemdiagnose kopiert werden. +- Ein `ResizeObserver` ordnet den realen Arbeitsbereich in `phone`, `narrow`, `compact`, `standard` oder `wide` ein. +- Neue globale CSS-Verträge verhindern Überbreite in allen Hauptseiten. + +## Sperrregeln + +- Keine `.env`, Datenbank, Uploads, Listings, Schlüssel, Logs oder Modelle im Releasepaket. +- Keine FASHN-/VTO-Funktion und kein `LoadImage` im freien FLUX-Workflow. +- ComfyUI ist optional; Nichterreichbarkeit ist ein Hinweis und kein Vendoo-Kernfehler. +- Diagnoseantworten geben keine API-Schlüssel oder Passwörter aus. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_VERIFICATION.json new file mode 100644 index 0000000..04fd57b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_26_1_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "version": "1.26.1", + "release": "Stability Responsive Regression Gate", + "static_verification": true, + "checks": { + "health_endpoint": true, + "admin_system_diagnostics": true, + "sqlite_quick_check": true, + "writable_path_probes": true, + "extension_presence_four_browsers": true, + "api_request_ids": true, + "api_timeout": true, + "get_retry_once": true, + "client_error_capture": true, + "workspace_resize_observer": true, + "ui_contract_verifier": true, + "database_migration_preservation": true, + "vto_removed": true, + "flux_workflow_without_loadimage": true + }, + "real_acceptance_pending": [ + "Windows target installation", + "interactive browser workflows", + "real ComfyUI/GPU probe", + "mapped W drive permissions" + ] +} \ No newline at end of file diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md new file mode 100644 index 0000000..6791600 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md @@ -0,0 +1,70 @@ +# Vendoo 1.30.0 – Operations Center + +Version: 1.30.0 + +## Ziel + +Vendoo erhält eine gemeinsame Betriebsgrundlage für verifizierte Sicherungen, kontrollierte Wiederherstellung, vorbereitete Offline-Updates, Rollback und Browser-Extension-Diagnose. + +## Backup und Wiederherstellung + +- vollständige Backups mit Datenbank, optionalen Uploads und optionaler `.env` +- reine Datenbank-Backups +- SQLite-Snapshot über die native Backup-Funktion +- `backup-manifest.json` mit SHA-256 je Datei +- SQLite-`integrity_check` vor Freigabe eines Backups +- manuelle Verifikation bereits erstellter Backups +- Download und kontrollierte Löschung +- automatische tägliche Sicherungen mit einstellbarer Uhrzeit und Rotation +- Wiederherstellung nur nach erfolgreicher Archivprüfung +- automatische Sicherheitssicherung vor jeder vorbereiteten Wiederherstellung +- Wiederherstellung wird ausschließlich offline über `setup.bat` Menüpunkt 11 angewendet + +## Update Center + +- installierte Version und registrierte Migrationen +- optionale Online-Prüfung über `VENDOO_UPDATE_MANIFEST_URL` +- lokaler Upload eines vollständigen Vendoo-ZIP-Pakets +- Prüfung auf gültiges Vendoo-Paket und höhere Zielversion +- Blockade von `.env`, Datenbank, Uploads, Backups, Logs, Modellen und `node_modules` im Update-Archiv +- verpflichtendes Backup-Gate vor dem Staging +- Offline-Anwendung über Setup-Menüpunkt 11 +- Code- und Laufzeit-Snapshot vor dem eigentlichen Austausch +- automatische Rückkehr zum vorherigen Stand bei einem Apply-Fehler +- manueller Rollback über Setup-Menüpunkt 12 + +## Extension-Diagnose + +Vendoo Link 1.9.0 meldet lokale Heartbeats mit: + +- Browserfamilie +- Extension-Version +- Plattformkontext +- letzter Aktivität +- Client-ID + +Der Diagnosebereich zeigt erwartete und erkannte Versionen, zuletzt aktive Clients und fehlende beziehungsweise veraltete Verbindungen. Es werden keine Seiteninhalte, Passwörter oder API-Schlüssel übertragen. + +## Datenbank + +Neue additive Tabellen: + +- `operation_backups` +- `operation_settings` +- `update_operations` +- `system_migrations` +- `extension_clients` + +Registrierte Migration: + +- `operations-center-1.30.0` + +Bestehende Artikel, Bilder, FLUX-Aufträge, Publishing-Aufträge und Einstellungen werden nicht gelöscht. + +## Sicherheitsgrenzen + +- Sicherungen mit `.env` enthalten Zugangsdaten und müssen vertraulich behandelt werden. +- Ein Restore oder Update wird nicht während des laufenden Vendoo-Servers angewendet. +- Update- und Restore-Archive werden zunächst in einen lokalen Staging-Bereich geschrieben und geprüft. +- ZIP-Pfadmanipulationen und absolute Archivpfade werden blockiert. +- Vendoo behauptet keine reale Windows-, PowerShell-, Restore-, Update- oder Rollback-Abnahme ohne Bestätigung auf dem Zielsystem. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_VERIFICATION.json new file mode 100644 index 0000000..974920f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_30_0_VERIFICATION.json @@ -0,0 +1,32 @@ +{ + "ok": true, + "version": "1.30.0", + "js_files": 72, + "json_files": 28, + "html_ids": 759, + "css_blocks": 3237, + "nav_targets": 16, + "extension_packages": 4, + "operations_markers": 59, + "verified_sqlite_backups": true, + "backup_manifest_sha256": true, + "automatic_rotation": true, + "restore_safety_backup": true, + "update_backup_gate": true, + "offline_apply": true, + "rollback_foundation": true, + "extension_heartbeat": true, + "zip_traversal_guard": true, + "native_runtime_test": false, + "native_runtime_test_reason": "Das produktive better-sqlite3-Modul und node_modules sind in der Build-Umgebung nicht vorhanden. Migrationen wurden mit SQLite und die JavaScript-/Archivlogik statisch beziehungsweise isoliert geprüft.", + "powershell_parser_test": false, + "powershell_parser_test_reason": "PowerShell 5.1 beziehungsweise pwsh ist in der Build-Umgebung nicht verfügbar. setup.bat enthält weiterhin den Parser-Guard für das Windows-Zielsystem.", + "real_target_acceptance": { + "windows_setup": false, + "backup_creation": false, + "restore_apply": false, + "update_apply": false, + "rollback": false, + "extension_heartbeat": false + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md new file mode 100644 index 0000000..10fa8b6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md @@ -0,0 +1,28 @@ +# Vendoo 1.31.0 – Mehrbenutzer- und Server-Sicherheitsgrundlage + +## Ziel + +Version 1.31.0 führt eine belastbare Grundlage für mehrere Benutzer und einen bewusst freigegebenen LAN-/Serverbetrieb ein. Bestehende Daten werden additiv migriert. + +## Rollen + +- Administrator: Vollzugriff einschließlich Benutzer, Sitzungen, Backups, Updates und Sicherheit. +- Manager: operative Leitung ohne Benutzer- und Systemverwaltung. +- Editor: Artikel, Medien, AI/FLUX, Bildproduktion und Qualität. +- Publisher: Prüfung und Veröffentlichung bei schreibgeschützten Artikelstammdaten. +- Leser: reiner Lesezugriff. + +## Sicherheitsverträge + +- Jede geschützte API wird serverseitig auf eine Berechtigung geprüft. +- Sitzungen besitzen absolute und inaktive Laufzeiten und können widerrufen werden. +- Wiederholte Fehlanmeldungen sperren das Konto zeitweise. +- Neue Passwörter benötigen mindestens zehn Zeichen, Klein-/Großbuchstaben, Zahl und Sonderzeichen. +- Artikelbearbeitung verwendet kurzlebige persistente Sperren. +- Standard-Bind-Adresse ist `127.0.0.1`; LAN-Betrieb muss ausdrücklich aktiviert werden. +- Host, Origin, Proxy-Vertrauen und Rate Limits sind über `.env` konfigurierbar. +- Sicherheitsrelevante Aktionen und verweigerte Berechtigungen werden protokolliert. + +## Nicht Bestandteil dieser Abnahme + +Ein realer Windows-/NAS-/Reverse-Proxy-Lauf, echtes HTTPS, Firewallregeln und parallele Browser-Sitzungen müssen auf dem Zielsystem geprüft werden. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_VERIFICATION.json new file mode 100644 index 0000000..cd21b7d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_31_0_VERIFICATION.json @@ -0,0 +1,31 @@ +{ + "release": "Vendoo Multiuser & Security Foundation", + "version": "1.31.0", + "status": "static_and_sqlite_verified", + "verified": [ + "all JavaScript and ESM files pass node --check", + "all JSON files parse successfully", + "public/index.html contains no duplicate IDs", + "CSS braces are balanced", + "five fixed roles and permission catalog are present", + "API permission guard and rate limiter are active", + "password policy, account lockout, session expiry and revocation are present", + "persistent article edit locks are present", + "audit filtering and CSV export are present", + "default bind host is 127.0.0.1", + "host/origin/proxy/session/rate settings are documented in .env.example", + "SQLite additive migration preserves an existing listing", + "resource lock uniqueness is enforced", + "legacy database migration suite remains successful", + "free FLUX workflow contains no LoadImage", + "no active FASHN or Virtual Try-on implementation is included", + "setup.ps1 contains exactly one UTF-8 BOM" + ], + "not_claimed": [ + "real Windows PowerShell 5.1 acceptance", + "real multi-browser concurrent edit test", + "real NAS or public reverse proxy deployment", + "real HTTPS and firewall acceptance", + "native better-sqlite3 server startup in the build environment" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md new file mode 100644 index 0000000..75c6d48 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md @@ -0,0 +1,91 @@ +# Vendoo 1.32.0 – Docker, NAS & Production Deployment Foundation + +## Ziel + +Vendoo kann weiterhin unverändert lokal unter Windows betrieben werden und zusätzlich reproduzierbar in Docker beziehungsweise Portainer laufen. Anwendungscode und persistente Nutzerdaten sind getrennt. FLUX/ComfyUI bleibt ein optionaler externer Dienst und wird nicht in den Vendoo-Hauptcontainer eingebaut. + +## Neue Deployment-Bausteine + +- mehrstufiges `Dockerfile` auf Node.js 22 +- reproduzierbare Installation über `npm ci` und Lockfile +- Ausführung als nicht privilegierter Benutzer `node` (UID/GID 1000) +- `read_only`-Container, `no-new-privileges`, alle Linux-Capabilities entfernt +- getrennte persistente Volumes für DB, Uploads, Backups, Operations und Logs +- `/healthz` für Liveness und `/readyz` für DB-/Schreibpfad-Readiness +- geordneter SIGTERM-/SIGINT-Shutdown mit SQLite-WAL-Checkpoint +- zentral konfigurierbare Laufzeitpfade, bei Windows aber identische Standardpfade wie zuvor +- Erst-Admin-Schutz über einen mindestens 32 Zeichen langen `VENDOO_SETUP_TOKEN`; bis zur Einrichtung bleiben alle übrigen API-Routen gesperrt +- konfigurierbare Secure-/SameSite-Cookies +- kontrollierte Browser-Extension-Origin-Allowlist +- Uploads nur noch nach Anmeldung abrufbar +- strengere Bildannahme: zulässige Endung und Bild-MIME müssen zusammenpassen +- Docker-Backup-/Restore-Hilfsskripte für Named Volumes und NAS-Bind-Mounts sowie ein Bind-Mount-Beispiel +- begrenzte Docker-Logrotation (`10m`, fünf Dateien) + +## Laufzeitpfade + +| Variable | Docker-Standard | Windows ohne Variable | +|---|---|---| +| `VENDOO_DATA_DIR` | `/app/data` | Projektordner | +| `VENDOO_DB_PATH` | `/app/data/db/vendoo.db` | `db/vendoo.db` | +| `VENDOO_UPLOADS_DIR` | `/app/data/uploads` | `uploads/` | +| `VENDOO_BACKUP_DIR` | `/app/data/backups` | `backups/` | +| `VENDOO_OPERATIONS_DIR` | `/app/data/operations` | `operations/` | +| `VENDOO_LOG_DIR` | `/app/data/logs` | `logs/` | + +Leere Variablen fallen auf die kompatiblen Standardpfade zurück. + +## Reverse Proxy + +Der Compose-Port wird standardmäßig ausschließlich an `127.0.0.1` gebunden. Für ein NAS kann ein lokaler Reverse Proxy entweder denselben Docker-Netzwerkpfad verwenden oder der Host-Port bewusst im vertrauenswürdigen LAN bereitgestellt werden. Eine direkte öffentliche Portweiterleitung auf 8124 ist kein unterstützter Produktionsweg. + +Erforderlich hinter HTTPS: + +```env +PUBLIC_BASE_URL=https://vendoo.example.de +VENDOO_ALLOWED_HOSTS=vendoo.example.de +VENDOO_ALLOWED_ORIGINS=https://vendoo.example.de +VENDOO_TRUST_PROXY=1 +VENDOO_COOKIE_SECURE=true +VENDOO_COOKIE_SAMESITE=lax +``` + +`VENDOO_TRUST_PROXY=1` bedeutet genau einen vertrauenswürdigen Proxy-Hop. Bei anderer Topologie muss die reale Hop-Anzahl oder ein zulässiger Express-Proxybereich gesetzt werden. + +## Browser-Erweiterungen + +Im Produktionsmodus ist die pauschale Freigabe aller Extension-Origins deaktiviert. Konkrete IDs werden eingetragen, zum Beispiel: + +```env +VENDOO_EXTENSION_ORIGINS=chrome-extension://abcdefghijklmnop,moz-extension://uuid +VENDOO_ALLOW_ANY_EXTENSION_ORIGIN=false +``` + +Die bestehende Cookie-basierte Anmeldung bleibt kompatibel. Eine eigene widerrufbare Extension-Tokenkopplung ist bewusst nicht vorgezogen worden und bleibt Teil des späteren Extension Distribution Centers. + +## FLUX/ComfyUI + +Der Hauptcontainer startet standardmäßig mit: + +```env +LOCAL_IMAGE_ENABLED=false +``` + +Für einen getrennten GPU-Dienst: + +```env +LOCAL_IMAGE_ENABLED=true +LOCAL_IMAGE_URL=http://comfyui:8188 +LOCAL_IMAGE_TOKEN= +LOCAL_IMAGE_REQUIRE_TOKEN=true +LOCAL_IMAGE_ALLOW_REMOTE=true +``` + +ComfyUI darf nicht direkt öffentlich freigegeben werden. Das freie FLUX-Prompt-Workflow bleibt textbasiert; es wird kein Produktbild oder `LoadImage`-Knoten eingeschleust. + +## Updateprinzip + +- Windows: bestehender Drüberkopierablauf und `setup.bat` Menüpunkt 3 bleiben gültig. +- Docker: Nutzervolumes bleiben bestehen; nur Image/Code wird neu gebaut und der Container ersetzt. +- Vor jedem Update DB und Uploads gemeinsam sichern. +- Bei fehlgeschlagener Readiness nicht weiterarbeiten, sondern Logs und Volume-Rechte prüfen und gegebenenfalls auf das vorherige Image zurückgehen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_VERIFICATION.json new file mode 100644 index 0000000..16f4dc7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_32_0_VERIFICATION.json @@ -0,0 +1,35 @@ +{ + "schema": "vendoo-release-verification-v1", + "version": "1.32.0", + "date": "2026-07-09", + "basis_sha256": "cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b", + "basis_files_verified": 271, + "passed": [ + "handover-read-order", + "basis-sha256", + "basis-file-checksums", + "version-sync", + "javascript-syntax", + "shell-syntax", + "yaml-static-parse", + "fresh-db-migration", + "existing-db-migration-data-preservation", + "npm-lock-resolution-ignore-scripts", + "runtime-path-writability", + "setup-token-entrypoint", + "deployment-static-gate", + "data-hygiene", + "secret-pattern-scan", + "flux-separation-no-vto" + ], + "not_executed": { + "full-npm-ci-native-scripts": "better-sqlite3 native compilation exceeded sandbox execution limits after prebuild/network unavailability", + "server-api-smoke": "native better-sqlite3 binary unavailable", + "docker-build-compose-runtime": "docker command unavailable, exit 127", + "windows-powershell-runtime": "Windows and PowerShell unavailable", + "browser-ui-e2e": "browser runtime unavailable", + "reverse-proxy-https-live": "proxy/TLS environment unavailable", + "platform-api-publishing": "intentionally not triggered" + }, + "release_position": "package-ready-for-real-environment-acceptance" +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md new file mode 100644 index 0000000..9c81102 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md @@ -0,0 +1,26 @@ +# Vendoo 1.33.0 – Multiplatform Deployment Wizard & Flux/Admin Fixes + +Dieser Release ergänzt die Docker-/NAS-Grundlage aus 1.32.0 um native, geführte Installationswege für Windows, Docker Desktop, Linux, NAS/Portainer und VPS. Zusätzlich startet FLUX Studio neutral und die Benutzerbearbeitung wurde stabilisiert. + +## Installer-Architektur + +- Windows: `setup.bat` → `setup.ps1` +- Linux/NAS/VPS: `setup.sh` +- gemeinsamer Kern: `.env`, Compose, Volumes, Healthchecks, Backup und Restore +- keine automatische unsichere Ferninstallation auf einen VPS +- Zielsystemabhängige native Skripte statt zusätzlicher Pflicht für PowerShell 7 auf NAS/VPS + +## FLUX + +- keine automatische Auswahl aus der Bibliothek +- Reset für Prompt, Einstellungen, Vorschau und Varianten +- Verlauf und Jobs bleiben unverändert +- FLUX bleibt ein separater Text-zu-Bild-Dienst + +## Benutzerverwaltung + +- stabile Listener nach jedem Rendern der Benutzerliste +- sicher escaped Inhalte +- konsistenter Benutzer-Cache +- Vorabvalidierung für Passwortänderungen +- sanitisierte Serverantwort nach Benutzeränderung diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md new file mode 100644 index 0000000..3f1c6d6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md @@ -0,0 +1,44 @@ +# Vendoo 1.34.0 – GitHub CI/CD & Zero-Edit Installation Foundation + +## Ziel + +Version 1.34.0 verbindet die vorhandene Multiplatform-Grundlage mit einer produktionsnahen GitHub- und Release-Struktur und beseitigt manuelle Konfigurationsschritte aus der Installation. + +## Architektur + +### Installation + +- Windows: WinForms-Assistent über `setup-gui.ps1`. +- Windows-Konsole: bestehendes Setup-Menü plus geführter Konfigurationspunkt. +- Linux/macOS/NAS/VPS: POSIX-kompatibler Dialogassistent über `setup.sh`. +- VPS: separater Compose-Stack mit Caddy und automatischem HTTPS. + +### Konfiguration + +- Deployment-Werte werden durch Installer in `.env` geschrieben. +- App-Eingabemasken schreiben veränderliche Schlüssel in einen persistenten Runtime-Overlay-Speicher. +- `bootstrap.mjs` lädt zuerst die Installationskonfiguration und danach den Runtime-Overlay. +- Docker verwendet ein eigenes Volume `vendoo_config`. +- Das Container-Root-Dateisystem bleibt read-only. + +### GitHub + +- CI auf Push und Pull Request. +- Release-Automation auf veröffentlichte GitHub Releases. +- GHCR-Images für AMD64 und ARM64 mit Digest-basierter Provenienz-Attestierung. +- Dependabot für npm, GitHub Actions und Docker. +- CODEOWNERS und Pull-Request-Checkliste. +- kontrollierter Erstimport über Release-Branch. + +## Sicherheitsentscheidungen + +- Kein direkter Push des unkontrollierten Projektordners nach `main`. +- Keine automatische VPS-Ferninstallation über gespeicherte SSH-Zugangsdaten. +- Kein `latest`-Deployment ohne Release-Freigabe. +- Kein Speichern von Secrets in GitHub-Workflowdateien. +- Kein Aufweichen des read-only Containers für die Einstellungsmaske. +- Keine automatische Aufnahme des Runtime-Secret-Stores in normale Backups. + +## Bewusst offen + +Ein echtes Production-CD mit SSH oder selbst gehostetem Runner ist nicht aktiviert. Es folgt erst nach praktischer NAS-/VPS-Abnahme mit Backup, Restore und Rollback. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_VERIFICATION.json new file mode 100644 index 0000000..5056b49 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_0_VERIFICATION.json @@ -0,0 +1,51 @@ +{ + "release": "1.34.0", + "date": "2026-07-09", + "base": "1.33.0", + "github": { + "repository": "Masterluke77/vendoo", + "branch": "release/1.34.0-initial-import", + "draft_pull_request": 3, + "status_commit": "c8f739afcff9024c1a7b66281484dc9cc1793394", + "full_source_push_required": true + }, + "checks": { + "javascript_mjs_syntax": "passed", + "shell_syntax": "passed", + "yaml_parse": "passed", + "git_safety": { + "status": "passed", + "files_checked": 314 + }, + "deployment_gate": "passed", + "runtime_config_store": "passed", + "shell_installer_simulation": { + "status": "passed", + "modes": [ + "docker", + "nas", + "vps" + ] + }, + "database_migrations": { + "status": "passed", + "fresh_exec_blocks": 68, + "existing_exec_blocks": 68, + "existing_row_preserved": true + }, + "release_builder": { + "status": "passed", + "files_hashed": 314 + } + }, + "not_executed": [ + "Windows WinForms and PowerShell 5.1 runtime", + "real Docker build and container start", + "Docker Desktop runtime on Windows or macOS", + "real Portainer/NAS import", + "real Caddy TLS issuance and public DNS", + "browser end-to-end test", + "native better-sqlite3 Node runtime", + "published GitHub Release and GHCR push" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md new file mode 100644 index 0000000..f890c51 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md @@ -0,0 +1,27 @@ +# Vendoo 1.34.1 – Git-Sicherheitsgate Windows Hotfix + +## Anlass + +Beim ersten praktischen Lauf des GitHub-Deploy-Assistenten unter Windows brach das Git-Sicherheitsgate mit einer pauschalen Fehlermeldung ab. Die Analyse zeigte zwei relevante Ursachen: + +1. `new URL(...).pathname` lieferte unter Windows keinen zuverlässig nutzbaren Dateisystempfad. +2. In einem geklonten Repository wurden bevorzugt nur bereits von Git verfolgte Dateien geprüft. Neu kopierte, noch unversionierte Dateien waren dadurch nicht Teil des erweiterten Secret-Scans. + +## Korrektur + +- Root-Pfade werden mit `fileURLToPath(import.meta.url)` erzeugt. +- Das Gate scannt immer den vollständigen Arbeitsbaum vor `git add`. +- Environment-Dateien wie `.env`, `.env.local`, `.env.production` und vergleichbare Varianten werden blockiert. +- Der Deploy-Assistent verlangt Node.js und überspringt den erweiterten Scan nicht mehr. +- Bei einem Treffer werden die konkreten Dateien angezeigt und zusätzlich nach `logs/github-deploy-safety-report.txt` geschrieben. +- Ein Regressionstest erzeugt absichtlich unversionierte Test-Secrets und erwartet zwingend eine Blockierung. + +## GitHub + +- Neuer Importbranch: `release/1.34.1-initial-import` +- Neuer Draft Pull Request: `#4` +- Der alte Draft Pull Request `#3` wurde geschlossen und als ersetzt markiert. + +## Datenhaltung + +Der Hotfix verändert oder löscht keine Datenbank, Uploads, Backups, `.env`, API-Schlüssel, Benutzer, Artikel, FLUX-Modelle oder ComfyUI-Daten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_VERIFICATION.json new file mode 100644 index 0000000..1fcb68f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_1_VERIFICATION.json @@ -0,0 +1,17 @@ +{ + "release": "1.34.1", + "status": "passed_static_and_regression", + "git": { + "branch": "release/1.34.1-initial-import", + "pull_request": 4, + "supersedes_pull_request": 3 + }, + "checks": [ + "windows-safe fileURLToPath root", + "full working-tree scan", + "untracked secret regression", + "deployment gate", + "shell syntax", + "version consistency" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md new file mode 100644 index 0000000..05aca1b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md @@ -0,0 +1,47 @@ +# Vendoo 1.34.2 – Git-Sicherheitsdiagnose & gemeinsamer Staging-Builder + +## Anlass + +Beim praktischen Windows-Lauf erschien nur: + +```text +Git-Sicherheitsgate fehlgeschlagen (4): +``` + +Die Zahl `4` ist die Anzahl der gefundenen Treffer, nicht der Prozess-Exitcode. Windows PowerShell 5.1 behandelte die über stderr ausgegebenen Detailzeilen jedoch als Fehlerstrom und brach die Ausgabe nach der Überschrift ab. + +Zusätzlich verwendeten Windows und Linux unterschiedliche Kopierregeln. Lokale `.env.*`-Dateien oder Operations-Laufzeitordner konnten dadurch zunächst in das temporäre Staging gelangen und wurden erst anschließend vom Gate blockiert. + +## Lösung + +### Einheitlicher Staging-Builder + +`tools/prepare-git-staging.mjs` wird auf allen Plattformen verwendet. Er: + +- erhält ausschließlich das `.git`-Verzeichnis des temporären Klons, +- entfernt alte Dateien aus dem temporären Arbeitsbaum, +- kopiert nur freigegebene Quelldateien, +- übernimmt `.env.example` und `.env.docker.example`, +- schließt produktive `.env.*`-Dateien aus, +- schließt Operations-, Upload-, Backup-, Log-, Datenbank- und Modelllaufzeitdaten aus, +- verändert niemals den echten Vendoo-Installationsordner. + +### Vollständige Diagnose unter Windows + +Der PowerShell-Assistent startet Node über `Start-Process` und leitet stdout und stderr in getrennte temporäre Dateien um. Anschließend werden alle Zeilen kontrolliert eingelesen und angezeigt. + +Bei einem echten Treffer entsteht: + +```text +logs/github-deploy-safety-report.txt +``` + +Der Bericht wird unter Windows automatisch im Editor geöffnet. + +## Sicherheit + +Der Filter ersetzt das Sicherheitsgate nicht. Nach dem bereinigten Kopiervorgang scannt `verify-git-safety.mjs` weiterhin den vollständigen Staging-Arbeitsbaum auf eingebettete Schlüssel, Tokens, private Schlüssel, unerwartet große Dateien und verbotene Laufzeitdaten. + +## Datenhaltung + +Der Hotfix verändert oder löscht keine Datenbank, Uploads, Backups, `.env`, API-Schlüssel, Benutzer, Artikel, FLUX-Modelle oder ComfyUI-Daten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_VERIFICATION.json new file mode 100644 index 0000000..971c384 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_2_VERIFICATION.json @@ -0,0 +1,20 @@ +{ + "release": "1.34.2", + "status": "passed_static_migration_and_regression", + "git": { + "branch": "release/1.34.2-initial-import", + "pull_request": 5, + "supersedes_pull_request": 4 + }, + "checks": [ + "shared cross-platform staging builder", + "environment and runtime staging exclusions", + "git metadata preservation", + "complete Windows stdout and stderr diagnostics", + "full working-tree secret scan", + "untracked secret regression", + "deployment gate", + "shell installer simulation", + "database migrations" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md new file mode 100644 index 0000000..43d63a9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md @@ -0,0 +1,17 @@ +# Vendoo 1.34.3 – Update-Archiv-Ausschluss + +## Ursache + +Lokale Update- und Slice-Sicherungen unter `updates/backups/` wurden vom Staging-Builder als normale Quelldateien behandelt. Das Sicherheitsgate blockierte anschließend darin enthaltene Installationsskripte korrekt. + +## Lösung + +- `updates/` wird im gemeinsamen Node-Staging-Builder vollständig ausgeschlossen. +- Dieselbe Filterfunktion wird vom GitHub-Import und vom Release-Builder verwendet. +- `.gitignore` enthält `updates/`. +- Das Sicherheitsgate betrachtet `updates/` weiterhin als verbotenen Laufzeitpfad. +- Ein Regressionstest bildet die drei real gemeldeten Dateien exakt nach. + +## Datenwirkung + +Der lokale Ordner `updates/` wird weder gelöscht noch verändert. Er wird ausschließlich nicht nach GitHub kopiert. Datenbank, Uploads, `.env`, Backups, Operations-Daten, FLUX-Modelle und ComfyUI-Daten bleiben unverändert. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_VERIFICATION.json new file mode 100644 index 0000000..19d1e02 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_34_3_VERIFICATION.json @@ -0,0 +1,33 @@ +{ + "release": "1.34.3", + "date": "2026-07-09", + "github": { + "branch": "release/1.34.3-initial-import", + "draft_pull_request": 6, + "previous_pull_request_closed": 5 + }, + "checks": { + "git_staging_regression": true, + "exact_reported_paths_excluded": 3, + "active_installer_files_preserved": [ + "setup.bat", + "setup.ps1", + "scripts/install-local-flux.ps1" + ], + "git_safety_files_checked": 327, + "migration_exec_blocks_fresh": 68, + "migration_exec_blocks_existing": 68, + "existing_row_preserved": true, + "shell_installer_modes": [ + "docker", + "nas", + "vps" + ] + }, + "not_runtime_tested": [ + "Windows PowerShell 5.1 on user machine", + "real local Git push", + "Docker/Portainer/VPS live deployment", + "GitHub Actions after full source import" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md new file mode 100644 index 0000000..b1699c9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md @@ -0,0 +1,64 @@ +# Vendoo 1.35.0 – Modular Platform Kernel, Design-System Contracts & Security Architecture Foundation + +## Ziel + +Version 1.35.0 stoppt den unkontrollierten weiteren Ausbau der großen Bestandsdateien und schafft eine verbindliche Plattformarchitektur. Die bestehende Anwendung bleibt funktionsfähig und wird nicht per Big-Bang ersetzt. + +## Implementiert + +### Plattformkernel + +- `app/kernel/platform-kernel.mjs` +- geordneter Start und Stop +- Lifecycle-Rollback bei fehlgeschlagenem Start +- Kernelzustand im Readiness-Check +- geordneter Kernelshutdown vor dem SQLite-Checkpoint + +### Modulsystem + +- zwölf validierte Bestandsmodule +- Manifestvertrag und JSON-Schema +- Abhängigkeitsauflösung mit Zykluserkennung +- Capability-Prüfung +- eindeutige Ressourcen-Ownership +- Start-Rollback bereits gestarteter Module +- Status `legacy-bridge` für noch nicht vollständig migrierte Bereiche + +### Sicherheit + +- Policy Engine mit Deny-by-default +- Routenregistrierung nur mit Modulbesitzer und Policy +- standardisierte Plattformfehler mit Request-ID +- geschützte Admin-Endpunkte für Kernel-, Modul- und Theme-Vertragsstatus +- Owner-basierter Event-Bus +- feste Feature-Flag-Registrierung +- isolierter Extension-Host bleibt standardmäßig deaktiviert + +### Design-System + +- 33 versionierte Design Tokens +- Light, Dark und High Contrast +- deterministische CSS- und Vertragsgenerierung +- Kompatibilitätsbrücke zu bestehenden CSS-Variablen +- Browser-Theme-Runtime mit Systempräferenz +- sichere Backend-Validierung für künftige Theme-Definitionen +- kein freies CSS, JavaScript oder externe URL-Werte + +### Frontend-Verträge + +- Frontend Module Registry mit `mount`/`unmount` +- Listener-Cleanup über `AbortController` +- Safe-DOM-Helfer auf Basis von `textContent` +- zentraler zukünftiger API-Client mit Same-Origin-Credentials, CSRF und standardisierten Fehlern + +## Neue interne Endpunkte + +Nur Benutzer mit `security.manage`: + +- `GET /api/security/platform` +- `GET /api/security/platform/modules` +- `GET /api/security/platform/theme-contract` + +## Nicht behauptet + +Version 1.35.0 ist die Foundation, nicht die vollständige Migration. `server.mjs`, `public/app.js`, `public/style.css` und `lib/db.mjs` bleiben weiterhin groß. Die Fachmodule werden ab 1.38.0 einzeln migriert. Der sichtbare Theme Manager folgt in einem eigenen Release. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_VERIFICATION.json new file mode 100644 index 0000000..52a03ba --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_35_0_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "release": "1.35.0", + "architecture": "modular-monolith", + "modules": 12, + "design_tokens": 33, + "themes": ["light", "dark", "contrast"], + "feature_flags": 4, + "policies": 2, + "new_registered_routes": 3, + "git_branch": "release/1.35.0-platform-kernel", + "draft_pull_request": 7, + "checks": { + "syntax": true, + "architecture_gate": true, + "deployment_gate": true, + "git_safety": true, + "git_safety_regression": true, + "git_staging_regression": true, + "config_store": true, + "installer_shell_simulation": true, + "fresh_db_migration_blocks": 68, + "existing_db_migration_blocks": 68, + "existing_row_preserved": true, + "npm_ci": false, + "npm_ci_reason": "Native better-sqlite3 build could not finish in the network-restricted, time-limited sandbox." + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md new file mode 100644 index 0000000..743a76a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md @@ -0,0 +1,34 @@ +# Vendoo 1.36.0 – Secure Theme Manager + +## Ziel + +Version 1.36.0 migriert den Theme-Bereich als erstes Fachmodul vollständig auf den Vendoo-Plattformkernel. Die sichtbare Verwaltung ist sicher, benutzerfreundlich und ohne manuelle Dateiänderungen nutzbar. + +## Architektur + +Das Modul `vendoo.themes` besitzt eigene Services, Repository-Funktionen, Tabellen, Policies und API-Verträge. Andere Fachbereiche bleiben zunächst als Legacy Bridges registriert. + +## Datenmodell + +- `theme_profiles`: administrative, validierte Theme-Profile +- `user_theme_preferences`: persönliche Darstellung pro Benutzer + +Die Migration ist additiv. Bestehende Vendoo-Daten werden nicht verändert oder gelöscht. + +## Sicherheitsmodell + +- Deny-by-default-Routen +- Benutzer dürfen nur die eigene Präferenz ändern +- administrative Profile und Systemstandard benötigen `settings.manage` +- ausschließlich freigegebene Design Tokens +- keine freie CSS-, JavaScript-, HTML-, URL- oder Dateipfad-Eingabe +- serverseitige Wiederholungsvalidierung +- Audit-Protokoll + +## Design-System + +Der Vertrag umfasst 42 typisierte Tokens und die integrierten Themes Light, Dark und High Contrast. Komponenten-Tokens steuern unter anderem Buttons, Eingaben, Karten, Dialoge, Fokusbreite und Mindestzielgröße. + +## Kompatibilität + +Windows lokal, Docker Desktop, NAS/Portainer und VPS bleiben unterstützt. FLUX bleibt ein getrennter Dienst. Originalbilder, Datenbank, Benutzer, Artikel, Uploads und Backups werden nicht überschrieben oder gelöscht. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_VERIFICATION.json new file mode 100644 index 0000000..896d11d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/RELEASE_1_36_0_VERIFICATION.json @@ -0,0 +1,50 @@ +{ + "release": "1.36.0", + "name": "Secure Theme Manager, Accessibility & Component Token Foundation", + "moduleCount": 12, + "nativeModules": [ + "vendoo.themes" + ], + "legacyBridgeCount": 11, + "designTokenCount": 42, + "builtinThemes": [ + "light", + "dark", + "contrast" + ], + "gates": [ + "check", + "verify:architecture", + "verify:themes", + "verify:deployment", + "verify:git", + "verify:git-regression", + "verify:git-staging", + "verify:config", + "verify:installer-shell", + "verify:db" + ], + "runtimeTestsNotClaimed": [ + "Windows browser E2E", + "Docker/NAS/VPS live start", + "Caddy TLS", + "GitHub Actions live run", + "native better-sqlite3 install in sandbox" + ], + "gitSafetyFilesChecked": 385, + "releaseStagingFilesChecksummed": 384, + "dependencyResolution": { + "command": "npm ci --ignore-scripts", + "packagesAdded": 151, + "result": "passed" + }, + "databaseVerification": { + "freshDbExecBlocks": 68, + "existingDbExecBlocks": 68, + "existingRowPreserved": true, + "themeTables": [ + "theme_profiles", + "user_theme_preferences" + ] + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_02_ACCEPTANCE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_02_ACCEPTANCE.md new file mode 100644 index 0000000..124fac5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_02_ACCEPTANCE.md @@ -0,0 +1,29 @@ +# Slice 02 – Listings / Historie Acceptance + +## Abnahmeziele + +- [x] freigegebene Vendoo-Designsprache übernommen +- [x] bestehende Listings-API unverändert weiterverwendet +- [x] Suche, Plattform-, Status- und Datumsfilter erhalten +- [x] Zeitraumauswahl ergänzt +- [x] CSV- und JSON-Export erhalten +- [x] Einzel- und Seitenauswahl umgesetzt +- [x] Batch Status, Preis, Duplizieren, Etiketten, Publish und Löschen umgesetzt +- [x] Inline-Statusänderung pro Listing erhalten +- [x] Detailansicht weiterhin erreichbar +- [x] Pagination mit direkter Seitenauswahl umgesetzt +- [x] Spaltenkonfiguration lokal gespeichert +- [x] Dark-Mode-Tokens verwendet +- [x] keine hardcodierten produktiven Secrets enthalten + +## Technische Prüfungen + +- `node --check public/app.js` +- `node --check server.mjs` +- `node --check lib/db.mjs` +- keine doppelten HTML-IDs +- ausgeglichene CSS-Klammerstruktur + +## Runtime-Hinweis + +Ein vollständiger Runtime-Test benötigt die plattformspezifische Installation von `better-sqlite3`. Das Paket enthält bewusst kein `node_modules`; Installation erfolgt über den gehärteten Installer beziehungsweise `npm ci`. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_05_ACCEPTANCE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_05_ACCEPTANCE.md new file mode 100644 index 0000000..80aca05 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_05_ACCEPTANCE.md @@ -0,0 +1,28 @@ +# Slice 05 – Abnahme Publish-Center + +## Funktionsumfang + +- [x] Smart Copy vollständig innerhalb Vendoo +- [x] direkte Etsy-/eBay-Veröffentlichung mit Integrationsstatus +- [x] Listing-Daten vor der Veröffentlichung inline bearbeitbar +- [x] eBay Queue ohne separates Modal steuerbar +- [x] Fehler, Wiederholungen und Queue-Status sichtbar +- [x] geplante Veröffentlichungen inline verwaltbar +- [x] Batch-Auswahl aus Listings wird übernommen +- [x] Fotos werden vollständig ohne Beschnitt dargestellt +- [x] responsive Layouts für Desktop, Tablet und Mobile +- [x] Dark Mode ausschließlich über zentrale CSS-Variablen + +## Technische Prüfungen + +- [x] `node --check server.mjs` +- [x] `node --check lib/db.mjs` +- [x] `node --check public/app.js` +- [x] keine doppelten HTML-IDs +- [x] ausgeglichene CSS-Klammerstruktur +- [x] keine `.env`, Datenbank, Uploads oder `node_modules` im Paket +- [x] bestehende API-Routen, Authentifizierung und CSRF-Logik beibehalten + +## Installation + +Als Update über `setup.bat` → **3 Update** installieren. Danach Browser mit `Strg + F5` neu laden. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_06_ACCEPTANCE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_06_ACCEPTANCE.md new file mode 100644 index 0000000..c63ebce --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_06_ACCEPTANCE.md @@ -0,0 +1,25 @@ +# Slice 06 – Abnahme + +## Funktionsabnahme + +- [x] Studio Flow öffnet den sichtbaren Listing-Editor. +- [x] gespeicherte Listings können vollständig bearbeitet werden. +- [x] Fotos können ergänzt, entfernt, sortiert und als Cover gesetzt werden. +- [x] der vorhandene Fotoeditor funktioniert im Generator und im Listing-Editor. +- [x] WYSIWYG-Änderungen erscheinen unmittelbar im HTML-Code. +- [x] HTML-Codeänderungen werden in den WYSIWYG-Editor zurückgeführt. +- [x] Generator und Listing-Editor rendern eine separate Plattformvorschau. +- [x] Rich-Text-HTML wird dauerhaft in `description_html` gespeichert. +- [x] Klartext bleibt in `description` als Fallback erhalten. +- [x] Plattformvorschauen laufen in sandboxed iframes. + +## Technische Abnahme + +- [x] DB-Migration für `description_html` im `MIGRATIONS`-Array. +- [x] `photos` und `tags` werden bei Updates als JSON gespeichert. +- [x] Listing-Updates verwenden eine feste Feld-Allowlist. +- [x] Client- und Server-Sanitizing für Rich-Text-HTML. +- [x] JavaScript-/ESM-Syntaxprüfung bestanden. +- [x] keine doppelten statischen HTML-IDs. +- [x] CSS-Klammerstruktur vollständig. +- [x] keine `.env`, Datenbanken, Uploads oder `node_modules` im Paket. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md new file mode 100644 index 0000000..939a13b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md @@ -0,0 +1,36 @@ +# Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload + +## Browser-Erweiterungen + +- Chrome: `extensions/chrome` +- Edge: `extensions/edge` +- Firefox: `extensions/firefox` +- neues Vendoo-Link-Icon in 16/32/48/128 px +- neues Popup nach freigegebenem Mockup +- neuer Vendoo Assistant mit produktzentrierter Detailansicht und Auto-Fill-Flow + +## HTML-/Plattformvorschau + +Im Generator und Listing-Editor ist HTML nicht mehr als dauerhaft zweite Spalte sichtbar. Innerhalb der Plattformvorschau kann zwischen **Plattformvorschau** und **HTML-Code** umgeschaltet werden. WYSIWYG und HTML bleiben bidirektional synchronisiert. + +## Responsive Layout + +Abnahmepunkte: + +- Desktop ab 1440 px +- Notebook 1180–1439 px +- iPad Querformat 1024 px +- Tablet Hochformat 768 px +- Smartphone 360–560 px + +Navigation, Generator, Listing-Editor, Studio Flow, Tabellen, Modale und Kontextbereiche werden je Breakpoint neu priorisiert. + +## Mobiler Foto-Upload + +1. Nutzer erzeugt in Generator oder Listing-Editor einen QR-Code. +2. Vendoo erstellt ein kryptografisch zufälliges, gehasht gespeichertes Upload-Token. +3. Der Link ist 20 Minuten gültig und auf maximal zehn Bilder begrenzt. +4. Smartphone lädt Bilder über eine eigenständige responsive Upload-Seite hoch. +5. Desktop pollt die Sitzung und übernimmt neue Bilder automatisch. + +`PUBLIC_BASE_URL` oder eine erreichbare LAN-/HTTPS-Adresse ist erforderlich, wenn der QR-Code von einem anderen Gerät geöffnet wird. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md new file mode 100644 index 0000000..3ed520e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md @@ -0,0 +1,33 @@ +# Slice 08 – Extension Auto-Fill & UX + +## Ziel + +Die Vinted-Erweiterung soll kompakt, hochwertig und sicher bedienbar sein. Das bloße Ausfüllen eines Formulars darf niemals automatisch einen Entwurf erzeugen oder eine Veröffentlichung auslösen. + +## Verbindliches Aktionsmodell + +1. **Nur ausfüllen** + - trägt Titel, Beschreibung, Preis, Tags und bestmöglich weitere Attribute ein + - wählt die Kategorie, sofern möglich + - speichert keinen Entwurf + - veröffentlicht nicht + - setzt keinen Publish-Status in Vendoo + +2. **Daten + Bilder ausfüllen** + - überträgt zusätzlich die Produktbilder + - speichert keinen Entwurf + - veröffentlicht nicht + +3. **Als Entwurf** und **Veröffentlichen** + - sind getrennte, explizite Abschlussaktionen + - befinden sich im Detailbereich unter einer aufklappbaren Sicherheitssektion + +## Browser + +Identische UI- und Funktionsbasis unter: + +- `extensions/chrome` +- `extensions/edge` +- `extensions/firefox` + +Version: `1.4.0` diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md new file mode 100644 index 0000000..5e5be6a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md @@ -0,0 +1,17 @@ +# Slice 11 – Dashboard-First & Responsive Hotfix + +## Umfang + +- Dashboard als erster Navigationspunkt und Standardansicht +- fließende KPI-Kacheln mit Auto-Fit +- responsive Sparklines und Diagramme +- ResizeObserver für Dashboard-Visualisierungen +- vollständige Produktbild-Darstellung im Studio Flow +- automatische Kartenverteilung für Desktop, Tablet und Mobile + +## Abnahme + +- keine Text-/Diagrammüberlagerung in KPI-Karten +- keine abgeschnittenen Bilder in „Bereit für den nächsten Schritt“ +- Dashboard öffnet nach dem Login zuerst +- Diagramme reagieren auf Fenster- und Sidebar-Breitenänderungen diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md new file mode 100644 index 0000000..6e859a3 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md @@ -0,0 +1,34 @@ +# Slice 22.1 – Setup-integrated FLUX recovery + +## Confirmed incident + +The 7-Zip process completed successfully with `Everything is Ok`, 54,878 files +and about 4,038 MB extracted. Windows PowerShell 5.1 nevertheless exposed an +empty `Process.ExitCode`, which the previous installer treated as a non-zero +failure. + +## Recovery contract + +The setup startup recovery activates only when all of these conditions match: + +1. `install-state.json` is `failed` and `can_resume=true`. +2. The stored message contains the empty 7-Zip exit-code failure and + `Everything is Ok`. +3. The 7-Zip stderr log is empty. +4. The stdout log contains the success marker. +5. The extracted portable tree contains both `ComfyUI\main.py` and + `python_embeded\python.exe`. +6. No matching installer or 7-Zip process is still active. +7. The final ComfyUI target is not already installed. + +The user starts only `setup.bat`. Setup then offers to continue automatically. +The complete extraction is reused; no repeat extraction or project reset occurs. + +## Required Windows acceptance + +- Run the provided one-click updater. +- Let the updater parser-check and back up the three affected files. +- Allow the newly launched `setup.bat` to continue the detected installation. +- Confirm the log contains `Vollständiger temporärer ComfyUI-Entpackstand wird wiederverwendet`. +- Confirm ComfyUI, Python, workflow, model and start/stop scripts are present. +- Confirm `http://127.0.0.1:8188/system_stats` is reachable after startup. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md new file mode 100644 index 0000000..5091ad8 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md @@ -0,0 +1,23 @@ +# Vendoo Slice 22.2 – Download Recovery 1.14.4 + +## Bestätigter Fehler + +Der asynchrone BITS-Aufruf konnte unter Windows PowerShell 5.1 ein leeres Jobobjekt liefern. Dadurch wurde `Get-BitsTransfer` mit einer leeren Job-ID aufgerufen. Der anschließende `Invoke-WebRequest`-Fallback zeigte bei großen Dateien nur die PowerShell-Standardmeldung „Webanforderung wird geschrieben“ und bot keine verlässliche Fortschritts- oder Wiederaufnahmefunktion. + +## Korrektur + +- BITS und `Invoke-WebRequest` wurden für große Binärdownloads ersetzt. +- Neuer .NET-Streaming-Downloader mit HTTP-Range-Wiederaufnahme. +- Vorhandene `.part`-Dateien bleiben erhalten. +- Fortschritt mit Prozent, MB, Gesamtgröße, MB/s und Restzeit. +- Statusdatei wird alle zwei Sekunden aktualisiert. +- Konsolen-Heartbeat alle zehn Sekunden. +- Bis zu fünf automatische Wiederholungen nach Netzwerkunterbrechungen. +- `setup.bat` erkennt einen unterbrochenen Modell-Download und setzt ihn ohne Menüeingabe fort. +- Der Ein-Klick-Updater beendet den alten Installerprozess, entfernt veraltete BITS-Jobs, sichert die Altdateien und startet Vendoo automatisch neu. + +## Geänderte Dateien + +- `setup.bat` +- `setup.ps1` +- `scripts/install-local-flux.ps1` diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md new file mode 100644 index 0000000..8e1c6c3 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md @@ -0,0 +1,22 @@ +# Vendoo Slice 22.3 – PowerShell 5.1 Parser Fix 1.14.5 + +## Fehlerbild + +Windows PowerShell 5.1 meldete beim Start von `scripts/install-local-flux.ps1`: + +```text +InvalidVariableReferenceWithDrive +Ungültiger Variablenverweis. Nach ":" folgte kein Zeichen ... +``` + +## Ursache + +Innerhalb zweier interpolierter Zeichenfolgen stand `$Label:`. PowerShell 5.1 interpretiert den Doppelpunkt direkt nach einem Variablennamen als mögliche Scope-/Laufwerksangabe. + +## Korrektur + +Beide Vorkommen wurden auf `${Label}:` geändert. Damit ist der Variablenname eindeutig begrenzt. + +## Updateweg + +Den vollständigen ZIP-Inhalt über den bestehenden Vendoo-Ordner kopieren und gleichnamige Dateien ersetzen. Den Zielordner nicht vorher löschen. Anschließend `setup.bat` doppelklicken. Vorhandene `.part`-Downloads bleiben erhalten und werden fortgesetzt. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_SHA256.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_SHA256.json new file mode 100644 index 0000000..fb7571f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_3_SHA256.json @@ -0,0 +1,6 @@ +{ + "setup.ps1": "bf5fc9f0a56e7d01f16419427f4bd3af1043c9b67e091fe595542ca49bbc164b", + "scripts/install-local-flux.ps1": "67488baffc35a5b6af4b3379468226a202611fff1ee63358992014a5e4de80fd", + "CHANGELOG.md": "8292be2828a0d1340318ba19b40dec55b8eeccf96171b903066a6fc9ac15e674", + "docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md": "95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269" +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_SHA256.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_SHA256.json new file mode 100644 index 0000000..2f56d4a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_SHA256.json @@ -0,0 +1,9 @@ +{ + "scripts/install-local-flux.ps1": "8ded6e1e3a311f36eb1f5be5165ee3b56b88b05b42b29826ce218cdc3b2ee848", + "lib/ai-local-images.mjs": "1439c483d7562a4fb16197edb2221e93c34ce1814e261ef1fe8c6fc26b9e767c", + "local-ai/workflows/vendoo_flux_schnell_api.json": "5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265", + "package.json": "ab72cdee8fbd1e4d8e743a19c90d2343beb7c960dc4a8642f2a94fc5ae4a3abb", + "CHANGELOG.md": "7c9e9de1e943052a910a6dd64e7dc56c427908a6e5542bd87cf00c5f90586f21", + "docs/SLICE_22_4_UTF8_BOM_FIX.md": "d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807", + "docs/SLICE_22_4_VERIFICATION.json": "f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe" +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_UTF8_BOM_FIX.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_UTF8_BOM_FIX.md new file mode 100644 index 0000000..66594f5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_UTF8_BOM_FIX.md @@ -0,0 +1,26 @@ +# Slice 22.4 – UTF-8-BOM-Fix für lokalen FLUX-Workflow + +## Reale Fehlerursache + +Windows PowerShell 5.1 schreibt bei `Set-Content -Encoding utf8` standardmäßig +ein UTF-8-BOM. Dadurch begann die Workflow-Datei unsichtbar mit `U+FEFF` vor +dem eigentlichen `{`. + +Node.js `JSON.parse()` meldete deshalb: + +```text +Unexpected token '', "{ ..." is not valid JSON +``` + +## Korrektur + +- zentrale `Write-Utf8NoBom`-Funktion im FLUX-Installer +- Workflow-JSON und Installationsstatus werden ohne BOM geschrieben +- Laufzeit entfernt defensiv ein vorhandenes BOM vor `JSON.parse()` +- mitgelieferte Workflow-Datei ist BOM-frei + +## Benutzerablauf + +Vollständigen ZIP-Stand entpacken und über den bestehenden Vendoo-Ordner +kopieren. Gleichnamige Dateien ersetzen, den Zielordner nicht löschen. +Danach `setup.bat` starten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_VERIFICATION.json new file mode 100644 index 0000000..f5e4128 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_22_4_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "version": "1.14.6", + "json_files_validated": 10, + "json_files_with_bom": [], + "node_syntax_checks": { + "lib/ai-local-images.mjs": { + "returncode": 0, + "stderr": "" + }, + "server.mjs": { + "returncode": 0, + "stderr": "" + }, + "public/app.js": { + "returncode": 0, + "stderr": "" + } + }, + "changed_files": [ + "scripts/install-local-flux.ps1", + "lib/ai-local-images.mjs", + "local-ai/workflows/vendoo_flux_schnell_api.json", + "package.json", + "CHANGELOG.md", + "docs/SLICE_22_4_UTF8_BOM_FIX.md" + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md new file mode 100644 index 0000000..e24c5ed --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md @@ -0,0 +1,52 @@ +# Slice 23 – FLUX Product Reference, Live Progress & Settings Command Center + +## Reale Ausgangsfehler + +1. Der lokale ComfyUI-Workflow war Text-to-Image. `__SOURCE_IMAGE__` wurde ersetzt, + aber von keinem Workflow-Knoten verwendet. Das Produktfoto hatte deshalb keinen + technischen Einfluss auf das Ergebnis. +2. AI-Bildjobs setzten beim Start einmal `progress = 10` und erst am Ende `100`. +3. Eine alte oder nicht lesbare `install-state.json` konnte `unknown` liefern, + obwohl alle realen Installationsdateien vorhanden waren. +4. Die Einstellungen bestanden aus einer langen, ungruppierten Liste. + +## Umsetzung + +### Produktreferenz + +- Upload des ersten Produktfotos über ComfyUI `/upload/image` +- dynamische Workflow-Erweiterung mit `LoadImage`, `ImageScale` und `VAEEncode` +- KSampler nutzt den referenzbasierten Latent-Input +- modusabhängige Denoise-Stärke plus einstellbare Referenztreue +- 768 × 768 als schneller Standard; 1024 × 1024 als optionale Qualitätsstufe +- verstärkte Prompt-Regeln für Farbe, Kapuze, Reißverschluss, Taschen, Logos, + Drucke, Nähte und sichtbare Gebrauchsspuren + +### Fortschritt + +- additive DB-Migration `ai_model_jobs.progress_message` +- Fortschrittsmeldungen für Vorprüfung, Providerwahl, Referenzupload, + ComfyUI-Rendering, Sicherheitsprüfung und Speicherung +- sichtbare Fortschrittskarte im Generator und Listing-Editor + +### Status und Administration + +- BOM-sicheres Lesen der Installationsstatusdatei +- reale Kernchecks haben Vorrang vor veraltetem Status +- eindeutige Zustände: running, stopped, disabled, not-installed +- sicherer PowerShell-Uninstaller mit automatischem Stoppen und Entfernen der + Windows-Autostartaufgabe +- Downloads bleiben standardmäßig erhalten + +### Einstellungen + +- Command-Center-Layout mit vier Bereichen +- prominenter FLUX-Master-Schalter +- Live-Gesundheitskarten +- technische Pfade und Logs nur noch in aufklappbaren erweiterten Bereichen + +## Grenzen + +Image-to-Image-Pipeline bindet das Produktfoto real ein und verbessert die +Artikelähnlichkeit deutlich, kann aber bei komplexen Logos oder extremen +Perspektivwechseln keine pixelgenaue Reproduktion garantieren. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_VERIFICATION.json new file mode 100644 index 0000000..75931ee --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_23_VERIFICATION.json @@ -0,0 +1,62 @@ +{ + "version": "1.15.0", + "slice": "23", + "date": "2026-07-07", + "node_syntax": "passed for all .js and .mjs files", + "json_validation": { + "files_checked": 12, + "bom_files": [] + }, + "html_validation": { + "duplicate_ids": [], + "required_settings_ids_present": true + }, + "powershell_static_validation": { + "unsafe_variable_colon_patterns": [], + "uninstaller_encoding": "UTF-8 with BOM for Windows PowerShell 5.1" + }, + "local_flux_mock_integration": { + "source_upload": true, + "load_image_node": true, + "image_scale_node": true, + "vae_encode_node": true, + "sampler_uses_reference_latent": true, + "reference_denoise_at_70_percent": 0.765, + "progress_events_observed": 7, + "final_progress": 100, + "default_resolution": 768 + }, + "limitations": [ + "No real Windows/PowerShell 5.1 runtime or physical GPU was available in this build environment. The user must validate the final ComfyUI render on the Vendoo Windows system." + ], + "changed_files": [ + "lib/ai-local-images.mjs", + "lib/ai-model-photos.mjs", + "lib/ai-model-jobs.mjs", + "lib/db.mjs", + "server.mjs", + "public/index.html", + "public/app.js", + "public/style.css", + "scripts/uninstall-local-flux.ps1", + "package.json", + "CHANGELOG.md", + "FEATURES.md", + "docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md" + ], + "sha256": { + "lib/ai-local-images.mjs": "a792c3d744f51352fd75e65c1dc29e8c3034b2526e7dfac2e0f21f2390696c5a", + "lib/ai-model-photos.mjs": "af58af492d95f4a2d5d6d8a6cd8d7eca93af90de2ef6401db5c6f713c525a886", + "lib/ai-model-jobs.mjs": "70e4046e7f47ff26d44fb8628851238c631711e910b617c8c5110ab0e0641c1a", + "lib/db.mjs": "71bcc63191c9cfabe2115a05c6a1d208fee1456015fab20845b3cd1fd2de07cd", + "server.mjs": "a577174d5ee885201406ca263a6489b2528aa24243b1c32c4b17b74faf5b725e", + "public/index.html": "3d718a6553e63484949e82c6d8544d979e3a7ff6e9361e513fe47e0f362b3972", + "public/app.js": "814c2ca5442bcab9cbfc7eac4883c34f1d003d0899f4d2eda25c0c9420175bb7", + "public/style.css": "8d2f4b26a4974164e8f0e2a2248121529a4ac5ef351f49cf6536134602c94b9d", + "scripts/uninstall-local-flux.ps1": "3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd", + "package.json": "f7a483b9f9796655e6266d5614d2789ce579592578ee4b4e6d18ce6b35cb31ce", + "CHANGELOG.md": "d55c9a2dd3715e4a597e583bef96d1974d62c1d0a0101888ab1ec5a4aea45ca1", + "FEATURES.md": "61c711f50b1e25e7388de922ef6ef5eef4322470882a4579c7fefd46582c4f9d", + "docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md": "6d7033671600bad73ae3f7fca8da95ce3db17ca16d88c80a16db8551d9d3d550" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md new file mode 100644 index 0000000..2479a76 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md @@ -0,0 +1,22 @@ +# Slice 28.1 – FLUX Runtime Health-Check + +Version: 1.20.1 + +## Ziel + +ComfyUI darf nicht mehr nach wenigen Sekunden als gestoppt gelten, während Python, Alembic und die ComfyUI-Datenbank noch initialisieren. + +## Umsetzung + +- Startskript wartet bis zu 180 Sekunden auf `http://127.0.0.1:8188/system_stats`. +- PID-, Status- und Diagnoseinformationen unter `logs/local-flux-runtime-state.json`. +- Laufzeitprotokolle: + - `logs/local-flux-runtime.log` + - `logs/local-flux-runtime-error.log` +- ANSI-Sequenzen werden bei der Anzeige entfernt. +- Ein vorzeitig beendeter Prozess wird sofort als Fehler erkannt. +- Für das Vendoo Prompt-Studio werden Custom Nodes deaktiviert, da der Workflow nur Standard-Nodes nutzt. + +## Bestandsschutz + +ComfyUI, FLUX-Modell, Downloads, Datenbank, Uploads und Einstellungen bleiben erhalten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md new file mode 100644 index 0000000..f0bd17c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md @@ -0,0 +1,31 @@ +# Slice 28 – FLUX Studio und vollständige VTO-Bereinigung + +## Ziel + +Vendoo verwendet keinen lokalen Virtual-Try-on-Stack mehr. FLUX/ComfyUI bleibt als eigenständiger, textbasierter Bildgenerator erhalten. + +## Entfernt + +- lokaler Python-/Torch-/ONNX-Bilddienst +- Modelbibliothek und zugehörige API-Routen +- alte Installations-, Start-, Stop- und Autostartpfade +- zugehörige Einstellungen, Datenbanktabelle und Benutzeroberfläche +- historische aktive Workflow-Pfade für Try-on + +Beim ersten Setup-Lauf werden vorhandene Runtime- und Autostartreste entfernt. Eigene frühere Modelbilder werden nicht vernichtet, sondern nach `uploads/ai-generated/legacy-models` verschoben. + +## FLUX Studio + +- eigener Hauptmenüpunkt +- freier Prompt +- Stil, Format, Seed und Schritte +- Live-Fortschritt +- Vorschau und Download +- persistente lokale Bibliothek +- Übernahme in den Listing-Generator +- nachträgliche Bearbeitung mit Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung +- bearbeitete Ausgaben als neue Version statt stiller Überschreibung + +## Bestandsschutz + +Nicht gelöscht werden Datenbank, Listings, normale Uploads, `.env`, API-Schlüssel, lokale FLUX-/ComfyUI-Installation, Modelle oder Downloads. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_VERIFICATION.json new file mode 100644 index 0000000..4153a09 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_28_VERIFICATION.json @@ -0,0 +1,24 @@ +{ + "slice": "28", + "version": "1.20.0", + "generated_at": "2026-07-08", + "checks": { + "javascript_esm_syntax": "passed", + "json_validation": "passed", + "html_duplicate_ids": "passed", + "css_brace_balance": "passed", + "sqlite_schema_and_cleanup_migration": "passed", + "public_npm_lock_registry": "passed", + "npm_lock_resolution_ignore_scripts": "passed_151_packages", + "flux_mock_prompt_workflow": "passed", + "flux_prompt_has_no_loadimage": "passed", + "powershell_bom_policy": "passed", + "encoding_scan": "passed", + "runtime_and_user_data_excluded": "passed" + }, + "notes": [ + "Native better-sqlite3 npm build could not finish in the Linux build environment because DNS access to GitHub/nodejs.org was unavailable.", + "Windows PowerShell 5.1 runtime acceptance remains to be performed on the target Windows system." + ], + "source_file_count": 191 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md new file mode 100644 index 0000000..0ab1b07 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md @@ -0,0 +1,12 @@ +# Slice 29 – FLUX Quality & Settings Tabs + +Version 1.21.0 optimiert FLUX.1-schnell für vier Schritte, ergänzt strukturierte Promptführung, Performanceprofile und eine horizontale Tab-Navigation in den Einstellungen. + +## Profile +- Schnell: 768 px, 4 Schritte +- Ausgewogen: bis 1024 px, 4 Schritte +- Detail: 1024 px, 6 Schritte +- Eigene Werte: freie Auswahl + +## Runtime +Auto wählt bei NVIDIA-GPUs ab 12 GB VRAM das schnelle Profil, unter 8 GB das Low-VRAM-Profil und sonst Ausgewogen. Unsupported ComfyUI-Flags werden nicht erzwungen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_VERIFICATION.json new file mode 100644 index 0000000..31e9091 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_29_VERIFICATION.json @@ -0,0 +1,24 @@ +{ + "version": "1.21.0", + "slice": "29", + "checks": { + "javascript_esm_files": 46, + "javascript_syntax": "passed", + "json_files": 14, + "json_validation": "passed", + "html_unique_ids": 484, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "mock_comfyui_prompt_flow": "passed", + "prompt_mode_negative_prompt_empty": true, + "flux_schnell_default_steps": 4, + "settings_top_tabs": "implemented" + }, + "limitations": [ + "Real Windows/NVIDIA performance depends on the installed GPU, VRAM, ComfyUI build and drivers.", + "The build environment did not run the Windows PowerShell parser." + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_FLUX_STUDIO_PRO.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_FLUX_STUDIO_PRO.md new file mode 100644 index 0000000..57c1b8b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_FLUX_STUDIO_PRO.md @@ -0,0 +1,68 @@ +# Slice 30 – FLUX Studio Pro + +**Version:** 1.22.0 +**Basis:** 1.21.0 / Slice 29 +**Ziel:** produktives, persistentes Prompt-Bildstudio mit ComfyUI-Jobsteuerung + +## Funktionsumfang + +### Varianten und Seeds + +Ein Auftrag erzeugt exakt 1, 2 oder 4 Varianten. Jede Variante ist ein eigener persistenter Datensatz und besitzt einen eigenen Seed. Ohne Seed Lock werden nach der ersten Variante zufällige, untereinander eindeutige Seeds erzeugt. Mit Seed Lock wird aus dem Basis-Seed eine reproduzierbare Folge `Basis-Seed + Variantenindex` gebildet. Ein wiederholter Auftrag übernimmt die vollständige Seed-Liste des Quellauftrags. + +### Job-Center + +`flux_prompt_jobs` speichert den Auftrag; `flux_prompt_job_variants` speichert jede Variante. Das Job-Center zeigt: + +- Auftrag und Status +- Prompt und final strukturierten Prompt +- Profil, Stil, Format, Schritte und Promptmodus +- Varianten-Seeds und ComfyUI-Prompt-IDs +- reale Bearbeitungsphase und Laufzeit +- Ergebnis, Fehler und Abbruchstatus + +Unterstützte Zustände sind `queued`, `running`, `cancelling`, `completed`, `completed_with_errors`, `failed` und `cancelled`. + +### ComfyUI-Anbindung + +Vendoo verwendet: + +- `POST /prompt` zur Übergabe +- `GET /queue` für laufende und wartende ComfyUI-Aufträge +- `POST /queue` zum gezielten Entfernen wartender Prompt-IDs +- `POST /interrupt` zum Abbruch des aktuell laufenden Auftrags +- `GET /history` und `GET /history/{prompt_id}` für Historie und Fertigstellung +- `GET /system_stats` für System-, GPU- und VRAM-Informationen +- `GET /view` für das fertige Bild + +Fehler einzelner Diagnose-Endpunkte werden getrennt angezeigt und machen die übrigen Systemdaten nicht unbrauchbar. + +### Neustart-Wiederaufnahme + +Beim Vendoo-Start werden persistente Varianten geprüft: + +- `running` mit gespeicherter `prompt_id`: Vendoo setzt die Überwachung über die ComfyUI-Historie fort. +- `running` ohne `prompt_id`: Variante wird sicher erneut in die Vendoo-Warteschlange gestellt. +- `queued`: bleibt wartend und wird durch den Worker übernommen. + +Es werden keine vorhandenen Ergebnisbilder, Jobs oder Nutzerdaten gelöscht. + +### Fortschritt + +Der FLUX-Studio-Pro-Pfad übergibt keine geschätzten Prozentwerte. Angezeigt werden ausschließlich beobachtbare Phasen wie Vorbereitung, Übergabe, Rendering, Download, Fertigstellung, Abbruch oder Fehler sowie die tatsächlich vergangene Zeit. + +### Bibliothek + +Ergebnisbilder speichern Auftrag, Variante, Seed, Profil, Stil, Format, Schritte, Promptmodus und Laufzeit. Favoriten sind persistent filterbar. „Ähnlich erneut erzeugen“ übernimmt Prompt und Parameter und startet bewusst mit einem neuen Seed. + +## Datenbankmigration + +Bestehende `flux_images` werden nur um optionale Felder ergänzt. Neue Tabellen werden mit `CREATE TABLE IF NOT EXISTS` angelegt. Bestehende Datenbankzeilen bleiben erhalten. Das Update enthält keine Datenbankdatei. + +## Unveränderte Sperrregeln + +- FLUX Studio ist ein reiner Text-Prompt-Generator. +- FASHN VTON und Virtual Try-on bleiben entfernt. +- Keine neue VTO-Engine. +- Keine produktive `.env`, Datenbank, Uploads, Logs, Schlüssel, ComfyUI-Binaries oder Modelle im Paket. +- Keine internen OpenAI-/Artifactory-Paketquellen. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_VERIFICATION.json new file mode 100644 index 0000000..131c7e4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_30_VERIFICATION.json @@ -0,0 +1,81 @@ +{ + "version": "1.22.0", + "slice": "30", + "generated_at": "2026-07-08", + "basis": "1.21.0 / Slice 29", + "checks": { + "javascript_esm_files": 47, + "javascript_syntax": "passed", + "json_files": 16, + "json_validation": "passed", + "html_unique_ids": 501, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "runtime_private_files_excluded": "passed", + "active_vto_deliverables_absent": "passed", + "prompt_workflow_load_image": false, + "persistent_job_tables": [ + "flux_prompt_jobs", + "flux_prompt_job_variants" + ], + "variant_counts": [ + 1, + 2, + 4 + ], + "phase_progress_without_estimated_percent": "implemented", + "comfyui_mock": { + "ok": true, + "prompt_id": "slice30-prompt-id", + "phases": [ + "preparing", + "submitting", + "submitted", + "rendering", + "downloading", + "completed" + ], + "queue_summary": { + "running": 1, + "pending": 1, + "total": 2 + }, + "gpu": "Mock GPU", + "interrupt": true, + "targeted_pending_cancel": true, + "targeted_running_cancel": true, + "cancelled_before_submit": true, + "prompt_submissions": 1, + "load_image_in_prompt_workflow": false + }, + "database_upgrade_simulation": { + "ok": true, + "fresh_db_exec_blocks": 38, + "existing_db_exec_blocks": 28, + "existing_row_preserved": true, + "tables": [ + "flux_images", + "flux_prompt_jobs", + "flux_prompt_job_variants" + ], + "new_flux_image_columns": [ + "favorite", + "job_id", + "parameters_json", + "prompt_mode", + "variant_index" + ] + } + }, + "limitations": [ + "Windows PowerShell 5.1 parser was not available in the build environment; setup.ps1 encoding and static package rules were checked only.", + "npm ci could not be completed in the isolated build environment because the public registry lookup returned EAI_AGAIN; the lockfile and .npmrc contain only registry.npmjs.org.", + "No real Windows setup update was executed.", + "No real NVIDIA GPU, VRAM, FLUX model or live ComfyUI render was available.", + "Live cancellation and restart recovery must be confirmed from user output on the target system." + ], + "source_package_sha256": "1a3edd9e9e3b0c4b8cf6c8566b6cb22a9133fdf599c139f980314a93ffca90b1" +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md new file mode 100644 index 0000000..ac3ea52 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md @@ -0,0 +1,72 @@ +# Slice 31 – Advanced Image Editor + +**Version:** 1.23.0 +**Basis:** Vendoo 1.22.0 / Slice 30 + +## Ziel + +Der bisherige einfache Fotoeditor wurde durch einen nicht destruktiven Editor für Produktbilder und FLUX-Ergebnisse ersetzt. Das Original bleibt dauerhaft erhalten; jede Speicherung erzeugt eine neue Datei und eine nachvollziehbare Version. + +## Funktionen + +### Nicht destruktive Bearbeitung + +- Originaldatei wird nie überschrieben +- persistente Versionenkette in `image_edit_versions` +- ältere Versionen als neue Bearbeitungsbasis auswählbar +- Original direkt wiederherstellbar +- neue Versionen bleiben nach Vendoo-Neustart nachvollziehbar + +### Arbeitsablauf + +- Undo und Redo innerhalb der aktuellen Sitzung +- Bearbeitungshistorie mit vollständigen Parametersnapshots +- Vorher-/Nachher-Ansicht +- Anzeige von Quell- und Ausgabeabmessungen + +### Geometrie + +- freier Zuschnitt +- Seitenverhältnisse Original, 1:1, 4:5, 3:4 und 16:9 +- 90-Grad-Drehung links/rechts +- horizontales und vertikales Spiegeln +- Begradigung von -15 bis +15 Grad + +### Licht und Farbe + +- Belichtung +- Helligkeit +- Kontrast +- Lichter +- Schatten +- Weißabgleich/Temperatur +- Sättigung +- Schärfe + +### Export + +- JPEG, WebP oder PNG +- Qualitätsstufe 40–100 für JPEG/WebP +- Originalgröße oder maximale Zielgröße 2048, 1600, 1200 Pixel +- benutzerdefinierte Breite und Höhe mit proportionalem `inside`-Fit +- kein Hochskalieren über die vorhandene Auflösung + +## Technik + +- Vorschau im Browser mit Vanilla JavaScript und Canvas +- produktives Rendering serverseitig mit dem bereits vorhandenen `sharp` +- normalisierte Zuschnittkoordinaten für auflösungsunabhängige Exporte +- additive SQLite-Migration ohne Änderung bestehender Listings oder Bilder +- Pfadvalidierung beschränkt Bearbeitungen strikt auf `uploads/` +- bei fehlgeschlagener Versionsprotokollierung wird die neu erzeugte Datei wieder entfernt + +## Bewusste Abgrenzung + +Nicht enthalten und nicht als Platzhalter dargestellt: + +- Hintergrundentfernung +- Inpainting +- Outpainting +- Maskeneditor + +Diese Funktionen bleiben einem späteren, technisch separat ausgewählten und abgenommenen Slice vorbehalten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_VERIFICATION.json new file mode 100644 index 0000000..3893dd0 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_31_VERIFICATION.json @@ -0,0 +1,137 @@ +{ + "version": "1.23.0", + "slice": "31", + "generated_at": "2026-07-08", + "basis": "1.22.0 / Slice 30", + "checks": { + "javascript_esm_files": 49, + "javascript_syntax": "passed", + "json_validation": "passed", + "html_unique_ids": 537, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "runtime_private_files_excluded": "passed", + "active_vto_deliverables_absent": "passed", + "advanced_editor_controls": [ + "undo", + "redo", + "session_history", + "restore_original", + "before_after", + "free_crop", + "ratio_original", + "ratio_1_1", + "ratio_4_5", + "ratio_3_4", + "ratio_16_9", + "rotate_left", + "rotate_right", + "flip_horizontal", + "flip_vertical", + "straighten", + "exposure", + "brightness", + "contrast", + "highlights", + "shadows", + "temperature", + "saturation", + "sharpness", + "jpeg_export", + "webp_export", + "png_export", + "quality", + "target_size" + ], + "persistent_version_table": "image_edit_versions", + "image_editor_render_test": { + "ok": true, + "source": { + "width": 960, + "height": 720, + "format": "png" + }, + "output": { + "width": 544, + "height": 640, + "format": "webp", + "filename": "verification/3e3792cd-714f-4cf3-82a8-1d06fe3803af.webp" + }, + "operations": { + "undo_redo_state_shape": true, + "crop": { + "x": 0.08, + "y": 0.1, + "width": 0.84, + "height": 0.76 + }, + "rotation": 90, + "straighten": 2.5, + "tone_controls": [ + "exposure", + "brightness", + "contrast", + "highlights", + "shadows", + "temperature", + "saturation", + "sharpness" + ], + "resize": { + "width": 640, + "height": 640 + }, + "quality": 84 + }, + "non_destructive": true, + "version_recorded": true, + "traversal_blocked": true + }, + "database_upgrade_simulation": { + "ok": true, + "fresh_db_exec_blocks": 41, + "existing_db_exec_blocks": 41, + "existing_row_preserved": true, + "tables": [ + "flux_images", + "flux_prompt_jobs", + "flux_prompt_job_variants", + "image_edit_versions" + ], + "new_flux_image_columns": [ + "favorite", + "job_id", + "parameters_json", + "prompt_mode", + "variant_index" + ], + "image_edit_version_columns": [ + "context", + "created_at", + "file_size", + "format", + "height", + "operations_json", + "output_path", + "quality", + "root_path", + "source_path", + "user_id", + "width" + ] + }, + "source_package_sha256": "5fdda17c3edce978b37de6ee73ae305e016b6c96cc406b75b783cd80a708b0c1", + "json_files": 17 + }, + "limitations": [ + "No real Windows setup update was executed in the build environment.", + "Windows PowerShell 5.1 was not available; setup.ps1 encoding and static package rules were checked only.", + "No interactive browser acceptance with the user's real product or FLUX images was possible.", + "The image rendering pipeline was tested with Sharp in the Linux build environment; final Windows rendering must be confirmed on the target system.", + "npm ci could not be completed because the isolated environment could not resolve GitHub/Node.js downloads for the native better-sqlite3 dependency; Slice 31 adds no new npm dependency.", + "No live NVIDIA GPU or ComfyUI regression test was executed; Slice 31 does not change the FLUX generation workflow." + ] +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_BATCH_IMAGE_FACTORY.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_BATCH_IMAGE_FACTORY.md new file mode 100644 index 0000000..987de08 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_BATCH_IMAGE_FACTORY.md @@ -0,0 +1,75 @@ +# Slice 32 – Batch Image Factory + +## Release + +- Vendoo-Version: `1.27.0` +- Basis: `1.26.1` +- Release-Typ: kumulatives Drüberkopier-Paket + +## Ziel + +Die zentrale Bildergalerie wird um eine produktive Batch-Bildverarbeitung ergänzt. Quellbilder bleiben unverändert; jede erzeugte Ausgabe wird als neue Bildversion gespeichert. + +## Funktionsumfang + +- eigener Hauptmenüpunkt **Bildproduktion** +- Auswahl von Bildern aus FLUX, AI, Artikeln, Editorversionen und früheren Batch-Ausgaben +- Übergabe ausgewählter Bilder direkt aus der Galerie +- mehrere Quellbilder und mehrere Produktionsprofile je Auftrag +- acht integrierte Profile: + - eBay Hauptbild + - Vinted Hochformat + - Kleinanzeigen + - Etsy Produktbild + - Instagram Quadrat + - Instagram Hochformat + - Webshop WebP + - Original optimiert +- eigene Produktionsprofile mit Zielbreite/-höhe, Fit-Modus, Hintergrund, Format, Qualität und Dateinamensschema +- optionale Hintergrundentfernung mit Sensitivität +- optionales Wasserzeichen mit Text, Position und Deckkraft +- persistente Aufträge und Einzelpositionen in SQLite +- Pause, Fortsetzen und Abbruch +- Retry aller fehlgeschlagenen Positionen +- Retry einzelner Positionen +- Wiederaufnahme nach Vendoo-Neustart +- 5/10/15 Aufträge pro Seite +- ZIP-Export aller fertigen Ausgaben eines Auftrags +- automatische Aufnahme der Ergebnisse in die Bildergalerie unter Quelle **Produktion** + +## Datenbank + +Neue additive Tabellen: + +- `image_batch_profiles` +- `image_batch_jobs` +- `image_batch_job_items` + +Bestehende Tabellen und Nutzerdaten werden nicht entfernt oder zurückgesetzt. + +## Dateisystem + +Ergebnisse werden unter folgendem Bereich erzeugt: + +```text +uploads/batch// +``` + +Die Originalbilder bleiben erhalten. Auftragsdaten können gelöscht werden, ohne die erzeugten Bilddateien zu löschen. + +## Renderer + +`lib/image-editor.mjs` unterstützt zusätzlich: + +- `resize.fit = inside | contain | cover` +- definierte Hintergrundfarbe bei `contain` +- kontrollierten Ausgabeordner +- kontrollierten Dateinamen +- Duplikatschutz durch automatische Nummerierung + +## Sperrregeln + +- FASHN und Virtual Try-on bleiben entfernt. +- FLUX bleibt ein reiner Prompt-Bildgenerator. +- Keine `.env`, Datenbank, Uploads, API-Schlüssel, Modelle oder `node_modules` werden ausgeliefert. +- Keine Nutzerdaten werden durch Migrationen gelöscht. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_VERIFICATION.json new file mode 100644 index 0000000..b6bd6c0 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_32_VERIFICATION.json @@ -0,0 +1,21 @@ +{ + "version": "1.27.0", + "slice": 32, + "name": "Batch Image Factory", + "static_release_gate": true, + "syntax_gate": true, + "html_id_gate": true, + "css_balance_gate": true, + "database_migration_gate": true, + "image_render_gate": true, + "built_in_profiles": 8, + "persistent_jobs": true, + "pause_resume_cancel_retry": true, + "restart_recovery": true, + "gallery_integration": true, + "zip_export": true, + "native_better_sqlite3_worker_test": false, + "native_test_note": "Die Build-Umgebung konnte das better-sqlite3-Binary wegen nicht erreichbarer externer Build-Artefakte nicht neu kompilieren. Tabellenmigrationen wurden mit Python SQLite geprüft; der Sharp-Renderer wurde nativ ausgeführt.", + "real_windows_acceptance": false, + "real_windows_acceptance_note": "Installation, Windows-PowerShell, produktive Laufwerke und Browserbedienung müssen auf dem Zielsystem bestätigt werden." +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md new file mode 100644 index 0000000..4713f0a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md @@ -0,0 +1,28 @@ +# Slice 33 – Artikel Quality Center + +Version: 1.28.0 + +## Ziel + +Vendoo prüft Artikel vor dem Publishing nachvollziehbar auf Vollständigkeit, Bildqualität, Organisation, mögliche Duplikate und Plattformtauglichkeit. Ergebnisse werden als Score von 0 bis 100 mit Noten A bis E dargestellt. + +## Funktionsumfang + +- eigener Navigationsbereich `Qualitätscenter` +- harte Blocker getrennt von Warnungen und Empfehlungen +- Prüfung von Titel, Beschreibung, Preis, Kategorie, Zustand, SKU, Lagerort, Tags und Bildern +- plattformbezogene Vendoo-Prüfprofile +- Bilddatei-, Format-, Abmessungs- und Auflösungsprüfung +- Duplikaterkennung über gleiche SKU und Titelähnlichkeit +- Gebühren und geschätzter Erlös im Bericht +- persistenter Score-Verlauf +- Suche, Filter und Pagination +- AI-Vorschläge über den aktiven Text-AI-Provider +- verpflichtender Vorher-/Nachher-Vergleich und Nutzerbestätigung vor dem Speichern + +## Sicherheitsregeln + +- AI darf keine Marken, Größen, Farben, Schäden, Echtheit, technischen Daten oder Lieferumfänge erfinden. +- AI-Vorschläge werden niemals automatisch gespeichert. +- Bestehende Artikel- und Bilddaten werden nicht destruktiv verändert. +- FASHN, VTO und Virtual Try-on bleiben entfernt. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_VERIFICATION.json new file mode 100644 index 0000000..6b14ad9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_33_VERIFICATION.json @@ -0,0 +1,18 @@ +{ + "version": "1.28.0", + "slice": 33, + "name": "Article Quality Center", + "gates": { + "quality_navigation": true, + "score_0_100": true, + "grades_a_to_e": true, + "blockers_warnings_recommendations": true, + "image_metadata_inspection": true, + "duplicate_detection": true, + "persistent_history": true, + "fees_and_net": true, + "ai_proposals_require_confirmation": true, + "responsive_workspace": true, + "vto_removed": true + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_PUBLISHING_HARDENING.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_PUBLISHING_HARDENING.md new file mode 100644 index 0000000..7917dc6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_PUBLISHING_HARDENING.md @@ -0,0 +1,44 @@ +# Slice 34 – Publishing Hardening + +Version: 1.29.0 + +## Ziel + +Der Publishing-Pfad wird von einzelnen Direktaktionen und einer separaten eBay-Warteschlange auf eine gemeinsame, persistente und nachvollziehbare Publishing-Queue umgestellt. + +## Funktionen + +- plattformübergreifende Queue für eBay, Etsy, Vinted und Kleinanzeigen +- API-Modus für eBay und Etsy +- bestätigungspflichtiger Extension-Modus für Vinted und Kleinanzeigen +- Schutz gegen parallele oder doppelte Veröffentlichungsaufträge +- Schutz gegen erneutes Veröffentlichen bereits als live protokollierter Artikel +- automatische Wiederholungen mit wachsendem Warteabstand +- Wiederaufnahme unterbrochener API-Aufträge nach Vendoo-Neustart +- Abbruch und manueller Retry +- sichere Löschung ausschließlich abgeschlossener lokaler Auftragsprotokolle +- Ereignis- und Fehlerprotokoll +- Speicherung externer IDs, URLs und eBay Offer-IDs +- eBay Statusabgleich, Aktualisierung und Beenden +- Etsy Statusabgleich +- geplante Veröffentlichungen werden an die gemeinsame Queue übergeben +- Vendoo Link 1.8.0 bestätigt Extension-Aufträge über den bestehenden Publish-Statusweg + +## Plattformgrenzen + +Vinted und Kleinanzeigen werden weiterhin nicht über private oder inoffizielle APIs automatisiert. Die Queue stellt den Artikel für die Browser-Extension bereit; der Nutzer bleibt für das Absenden und die Statusbestätigung verantwortlich. + +Etsy erzeugt mit dem bestehenden API-Ablauf zunächst einen Etsy-Entwurf. Vendoo kennzeichnet diesen Status ausdrücklich als „Entwurf erstellt“ und behauptet nicht, dass der Artikel bereits öffentlich live ist. + +## Datenbank + +Neue additive Tabellen: + +- `publishing_jobs` +- `publishing_events` + +Erweiterte Tabelle: + +- `publish_log` mit `offer_id`, `sync_status`, `last_synced_at`, `ended_at` und `updated_at` + +Es werden keine bestehenden Artikel, Bilder, API-Schlüssel, `.env`-Dateien oder Veröffentlichungsdaten gelöscht. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_VERIFICATION.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_VERIFICATION.json new file mode 100644 index 0000000..30b9a9c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/SLICE_34_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "version": "1.29.0", + "js_files": 69, + "json_files": 25, + "html_ids": 728, + "css_blocks": 3127, + "nav_targets": 16, + "extension_packages": 4, + "persistent_publishing_queue": true, + "duplicate_publish_guard": true, + "automatic_retry": true, + "restart_recovery": true, + "extension_completion": true, + "ebay_sync_update_end": true, + "etsy_sync": true, + "legacy_ebay_queue_migration": true, + "native_runtime_test": false, + "native_runtime_test_reason": "better-sqlite3 konnte in der Build-Umgebung wegen temporär nicht erreichbarer GitHub-/Node-Header nicht kompiliert werden.", + "real_platform_acceptance": { + "windows": false, + "ebay": false, + "etsy": false, + "vinted_extension": false, + "kleinanzeigen_extension": false + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_FOUNDATION_2026.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_FOUNDATION_2026.md new file mode 100644 index 0000000..427fb32 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_FOUNDATION_2026.md @@ -0,0 +1,41 @@ +# Vendoo UI Foundation 2026 + +## Status + +Die Designrichtung ist durch die freigegebenen Referenzen in `MockupDesign/` verbindlich festgelegt. + +## Leitidee + +Vendoo ist eine **Commerce Workbench**, kein generisches Statistik-Dashboard. + +Der zentrale Arbeitsfluss lautet: + +`Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` + +## Verbindliche Gestaltungsregeln + +- warme Off-White-Grundflächen und Graphit-Typografie +- Vendoo-Orange nur als gezielter Aktions- und Fokusakzent +- Produktbilder bilden die wichtigste visuelle Ebene +- Statusfarben transportieren Bedeutung statt Dekoration +- schmale Hauptnavigation und kontextuelle Detailflächen +- keine austauschbaren KPI-Kartenlandschaften +- keine violett-blauen Standardverläufe +- keine dekorativen Charts ohne operative Funktion +- wenige, bewusst eingesetzte Rundungen und Schatten +- Dark Mode wird eigenständig über CSS Custom Properties gestaltet + +## Slice 01 + +Dieser Slice führt ein: + +- neue App-Shell mit kompakter Navigation und Command Search +- zentrale Design-Tokens für Light und Dark Mode +- neue Startseite `Studio Flow` +- Workflow-Dashboard-API unter `/api/dashboard/workflow` +- Attention Queue, Activity Stream und Artikel-Inspector +- unveränderte Vanilla-JavaScript-/Express-/SQLite-Architektur + +## Noch nicht Teil dieses Slices + +Die vollständige visuelle Umstellung von Listings, Generator, Publish, Lager, Vorlagen, Einstellungen und Admin erfolgt in nachfolgenden isolierten Slices auf derselben Foundation. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_UX_FIXES_SLICE_03.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_UX_FIXES_SLICE_03.md new file mode 100644 index 0000000..a987762 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/UI_UX_FIXES_SLICE_03.md @@ -0,0 +1,26 @@ +# Vendoo UI 2026 – Slice 03 + +## Umfang + +### Print Studio + +Etiketten und Datenblätter werden innerhalb der Vendoo-Oberfläche vorbereitet. Das Print Studio bietet eine A4-Vorschau, Etikettenformate 90 × 50 mm und 62 × 29 mm, mehrere Exemplare sowie eine optionale Preisanzeige. Erst der abschließende Druckbefehl öffnet den normalen Systemdruckdialog. Es wird kein zusätzliches Browserfenster erzeugt. + +### Studio Flow Media Fit + +Produktbilder in Flow-Karten, Inspector und Attention Queue werden vollständig dargestellt. Die Medienflächen verwenden `object-fit: contain`, zentrierte Ausrichtung und neutrale Produktflächen. + +### Vinted Extension + +Die Extension verwendet die Vendoo-Designsprache mit warmen Flächen, Graphittypografie und Vendoo-Orange. Produktbilder werden nicht mehr direkt als HTTP-Bild in die Vinted-Seite eingebettet, sondern durch den Background-Service-Worker geladen und als Data-URL eingesetzt. Das vermeidet Blockaden durch Seiten-CSP oder gemischte Inhalte. + +## Geänderte Dateien + +- `public/app.js` +- `public/style.css` +- `extension/background.js` +- `extension/content.js` +- `extension/content.css` +- `extension/popup.html` +- `extension/popup.js` +- `extension/manifest.json` diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/DESIGN_SYSTEM_1_35_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/DESIGN_SYSTEM_1_35_0.md new file mode 100644 index 0000000..ebb4b33 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/DESIGN_SYSTEM_1_35_0.md @@ -0,0 +1,39 @@ +# Vendoo 1.35.0 – Design-System- und Theme-Vertrag + +## Quelle der Wahrheit + +Die editierbare Tokenquelle liegt unter: + +`public/design-system/tokens/source.json` + +Aus ihr erzeugt `tools/generate-design-tokens.mjs` deterministisch: + +- `public/design-system/tokens.css` +- `public/design-system/theme-contract.json` + +Direkte Änderungen an `tokens.css` sind nicht zulässig. + +## Ebenen + +- Primitive/semantische Farb-, Radius-, Schatten-, Dichte-, Layout- und Bewegungswerte +- CSS-Variablen mit Präfix `--vd-*` +- Kompatibilitätsbrücke für bestehende Variablen wie `--bg`, `--accent` und `--radius` + +## Themes + +1. `light` +2. `dark` +3. `contrast` +4. Browserpräferenz `system` über die Theme Runtime + +## Sicherheit + +Der zukünftige Theme Manager darf ausschließlich Tokens mit `customizable: true` verändern. Freies CSS, HTML, URLs, JavaScript, Schattenausdrücke und beliebige Variablennamen sind nicht erlaubt. Farben und Dimensionen werden typ- und bereichsgeprüft. Ein Theme wird nie ungeprüft als Stylesheet gespeichert. + +## Nächster Ausbau + +- persistente Themes pro Benutzer und systemweit +- Live-Vorschau in isoliertem Vorschaucontainer +- automatische Kontrastprüfung +- Import/Export eines versionierten Theme-JSON +- Versionsverlauf und Zurücksetzen diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MIGRATION_ROADMAP_1_35_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MIGRATION_ROADMAP_1_35_0.md new file mode 100644 index 0000000..60afce2 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MIGRATION_ROADMAP_1_35_0.md @@ -0,0 +1,25 @@ +# Vendoo Modularisierungs-Roadmap ab 1.35.0 + +## 1.35.0 – Foundation + +Kernel, Manifeste, Lifecycle, Event Bus, Feature Flags, Policy-/Routenverträge, Design Tokens und Architekturtests. + +## 1.36.0 – Theme Manager + +Persistente sichere Themeverwaltung, Vorschau, Import/Export, Kontrast- und Accessibility-Gates. + +## 1.37.0 – Security & Secrets + +Secret Provider, Schema Registry, strukturierte Logs, sichere Fehlergrenzen und CSP-Migrationsgrundlage. + +## 1.38.0 – Auth, Users & Settings + +Erster vollständig migrierter Backend-/Frontend-Modulverbund einschließlich Vertragstests. + +## 1.39.0 – Listings, Inventory & Media + +Fachmodule, Repository-Schicht, UI-Komponenten und Datenzugriffsgrenzen. + +## 1.40.0 – Publishing, AI & FLUX + +Adapterverträge, Job-Schnittstellen, FLUX-Modul und Vorbereitung des isolierten Extension-Hosts. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MODULE_SYSTEM_1_35_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MODULE_SYSTEM_1_35_0.md new file mode 100644 index 0000000..47ada7c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/MODULE_SYSTEM_1_35_0.md @@ -0,0 +1,31 @@ +# Vendoo 1.35.0 – Modulsystem + +## Ziel + +Vendoo wird schrittweise als modularer Monolith organisiert. Der bestehende Code bleibt in 1.35.0 funktional unverändert und wird über `legacy-bridge`-Manifeste erfasst. Neue Funktionen müssen die Kernelverträge verwenden; bestehende Bereiche werden in späteren Releases einzeln migriert. + +## Verbindliche Regeln + +1. Jedes Modul besitzt eine eindeutige ID `vendoo.` und ein validiertes `module.json`. +2. Abhängigkeiten müssen explizit und frei von Zyklen sein. +3. Neue Routen benötigen Besitzer, Policy, Auditklasse, Rate-Limit-Klasse und Stabilitätsstatus. +4. Unbekannte Policies verweigern den Zugriff. Es gibt keinen Allow-by-default-Fallback. +5. Module kommunizieren künftig über öffentliche Services, Capabilities und Events, nicht über interne Querimporte. +6. Ein Modul darf langfristig nur seine eigenen Tabellen beziehungsweise Repository-Schnittstellen verändern. +7. Externe Erweiterungen werden nicht im Vendoo-Hauptprozess ausgeführt. Ein isolierter Extension-Host bleibt bis zur sicheren Umsetzung deaktiviert. +8. Modulaktivierung durch bloßes Kopieren einer Datei ist unzulässig. + +## Status 1.35.0 + +Die Bereiche System, Security, Auth, Benutzer, Themes, Medien, Artikel, AI, FLUX Studio, Quality Center, Publishing und Operations sind registriert. Ihr Status `legacy-bridge` bedeutet: Der Vertrag existiert, die bestehende Implementierung liegt aber noch überwiegend in `server.mjs`, `lib/`, `public/app.js` und `public/style.css`. + +## Migrationsmethode + +Vendoo verwendet das Strangler-Verfahren: + +- neue Architektur neben dem Bestand aufbauen, +- einen fachlichen Bereich vollständig migrieren, +- Verhalten und Datenbestand prüfen, +- erst danach den alten Pfad entfernen. + +Kein Big-Bang-Refactoring, keine Framework-Migration und kein Datenbankreset. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md new file mode 100644 index 0000000..e17fc6f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md @@ -0,0 +1,29 @@ +# Vendoo 1.35.0 – Sicherheitsarchitektur + +## Neue Grundlage + +- Deny-by-default Policy Engine für neue Kernel-Routen +- explizite Routenverträge +- standardisierte interne Fehler mit Request-ID +- validierte Modulmanifeste und Abhängigkeiten +- Owner-basierter Event-Bus mit sauberem Listener-Abbau +- Feature Flags mit fester Registrierung und sicherem Environment-Override +- sichere Theme-Werte statt frei eingebbarem CSS +- geordneter Kernelstart und Kernelshutdown + +## Bestehender Schutz bleibt aktiv + +Sitzungen, CSRF, Rollen, Berechtigungen, Rate Limits, Origin-/Hostprüfung, Uploadschutz, Audit und Ressourcenlocks werden nicht ersetzt oder umgangen. + +## Offene Produktiv-Gates + +Diese Punkte werden bewusst nicht als bereits gelöst ausgegeben: + +- vollständige CSP ohne `unsafe-inline` +- zentral validierte Request-/Response-Schemas für alle Bestandsrouten +- verschlüsselter Secret Provider je Plattform +- isolierter Extension-Host +- vollständige Repository-Trennung aller Datenbankzugriffe +- automatisierte Browser-, Accessibility- und Penetrationstests + +Bis zu ihrer Umsetzung bleiben die entsprechenden Feature Flags deaktiviert oder die Altpfade unter bestehenden Schutzmechanismen aktiv. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/THEME_MANAGER_1_36_0.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/THEME_MANAGER_1_36_0.md new file mode 100644 index 0000000..428e219 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/architecture/THEME_MANAGER_1_36_0.md @@ -0,0 +1,41 @@ +# Vendoo Theme Manager 1.36.0 + +## Ziel + +Der Theme Manager verwaltet Darstellung ausschließlich über einen versionierten Design-Token-Vertrag. Freies CSS, JavaScript, URLs, Dateipfade und nicht freigegebene Tokens werden nicht akzeptiert. + +## Modulgrenze + +`vendoo.themes` ist ab 1.36.0 ein natives Modul. Es besitzt: + +- `theme_profiles` +- `user_theme_preferences` +- Systemstandard in der bestehenden Settings-Ablage +- Tokenvertrag und Accessibility-Prüfung +- Theme-APIs mit Deny-by-default-Policies + +Andere Module greifen nicht direkt auf diese Tabellen zu. + +## Berechtigungen + +- Jeder angemeldete Benutzer darf seine eigene Darstellung speichern. +- Eigene Theme-Profile, Import, Export, Löschen und Systemstandard benötigen `settings.manage`. +- Alle Änderungen an Profilen und Systemstandard werden auditiert. + +## Sichere Werte + +Anpassbar sind nur freigegebene Farb- und Pixel-Dimensionstokens. Farben werden auf Hex/RGB-Kanäle geprüft. Dimensionen besitzen Mindest- und Höchstwerte. Nicht anpassbare Schatten-, Bewegungs- und Layoutverträge können nicht über die Oberfläche überschrieben werden. + +## Accessibility + +Die Validierung prüft zentrale Text-, Flächen-, Marken-, Gefahren- und Linienkontraste. Zusätzlich unterstützt die Runtime: + +- High Contrast +- System-Farbschema +- reduzierte Bewegung +- kompakte, ausgewogene und großzügige UI-Dichte +- tokenisierte Fokus-Ringe und Mindest-Zielgrößen + +## Migration + +Bestehende `vendoo-theme`-LocalStorage-Werte werden von der Runtime als Fallback übernommen. Nach Anmeldung lädt die Oberfläche die serverseitige Benutzerpräferenz. Datenbanktabellen werden additiv angelegt; vorhandene Benutzer und Einstellungen bleiben erhalten. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/superpowers/specs/2026-07-06-autolister-features-design.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/superpowers/specs/2026-07-06-autolister-features-design.md new file mode 100644 index 0000000..5e7f7f7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/docs/superpowers/specs/2026-07-06-autolister-features-design.md @@ -0,0 +1,66 @@ +# AutoLister Feature-Erweiterungen + +## 1. Kategorie-Fixes + +### a) Auto-Matching nach AI-Generierung +- `showMeta()` ruft `/api/categories/:platform/match?q=` auf +- Bestes Ergebnis wird im Picker vorausgewählt +- Freitext-Fallback wenn kein Match (Score < 50) + +### b) Plattform-Wechsel aktualisiert Picker +- Plattform-Button-Click prüft ob Meta-Section sichtbar ist +- Wenn ja: Kategorie-Picker wird mit neuer Plattform neu initialisiert +- Bisheriger Wert wird als Suchquery gegen neue Plattform gematcht + +### c) Extension nutzt Server-API +- `content.js` lädt Kategorien via `/api/categories/vinted` statt lokaler Datei +- `vinted-categories.js` wird aus manifest.json entfernt (Datei bleibt als Fallback) +- Match-Funktion nutzt `/api/categories/vinted/match?q=` + +## 2. WYSIWYG ↔ HTML Sync + +- Quill `text-change` Event → HTML-Source-Textarea wird aktualisiert (`editor.root.innerHTML`) +- HTML-Source `input` Event → Quill übernimmt via `editor.root.innerHTML = textarea.value` +- Toolbar-Icons bekommen `title`-Attribute: Bold→"Fett", Italic→"Kursiv", Underline→"Unterstrichen", Strike→"Durchgestrichen", List Ordered→"Nummerierte Liste", List Bullet→"Aufzählung", Blockquote→"Zitat", Link→"Link einfügen", Clean→"Formatierung entfernen", Color→"Textfarbe", Background→"Hintergrundfarbe", Align→"Ausrichtung", Header→"Überschrift" + +## 3. Veröffentlichungsstatus in Historie + +- `loadHistory()` ruft zusätzlich `/api/publish/status?ids=` auf +- Pro Listing in der Liste: farbige Badges für jede Plattform + - Grün: veröffentlicht + - Orange: kopiert + - Kein Badge: nicht veröffentlicht +- Badges erscheinen neben dem Status-Badge in der History-Liste + +## 4. SKU/Artikelnummer-System + +### DB +- Neues Feld: `sku TEXT` in listings-Tabelle +- Auto-generiert: `AL-XXXX` (fortlaufend, 4-stellig zero-padded) +- Jederzeit editierbar auf eigene Bezeichnung + +### UI-Integration +- Generator Meta-Section: SKU-Feld (readonly, auto-generiert beim Erstellen) +- Historie-Liste: SKU als erste Info nach Thumbnail +- Detail-View: editierbares SKU-Feld +- Publish-Liste: SKU angezeigt + +### QR-Code +- Client-seitig via qrcode.js (CDN) +- QR-Inhalt: `{"sku":"AL-0001","id":42,"url":"http://localhost:8124/#listing/42"}` +- Angezeigt im Detail-View + +### Druck-Formate +**Kompakt-Etikett (~90×50mm):** +- QR links, rechts: SKU, Titel (max 40 Zeichen), Preis, Plattform +- 4 Etiketten pro A4-Seite bei Batch-Druck + +**Produkt-Datenblatt (A4):** +- Header: QR + SKU groß +- Foto-Thumbnails (max 4) +- Titel, Beschreibung (max 200 Zeichen), Marke, Kategorie, Größe, Farbe, Zustand, Preis +- Plattform, Erstelldatum + +### Druck-Buttons +- Detail-View: "Etikett drucken" + "Datenblatt drucken" +- Historie Batch-Bar: "Etiketten drucken" (mehrere auf einem Blatt) diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/README.txt b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/README.txt new file mode 100644 index 0000000..1cc6bc2 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/README.txt @@ -0,0 +1,14 @@ +VERALTETER ORDNER + +Die Browser-Erweiterungen befinden sich ab Version 1.3.0 unter: +- extensions/chrome +- extensions/edge +- extensions/firefox + +Bitte die bisher geladene Erweiterung entfernen und den passenden neuen Unterordner laden. + + +HOTFIX 1.9.0: +Nach dem Drueberkopieren die Erweiterung unter chrome://extensions bzw. edge://extensions mit "Neu laden" aktualisieren und Vinted neu laden. Die Zustandsauswahl darf danach das Titelfeld nicht mehr ueberschreiben. + +VERSION 1.9.0: Publishing-Queue-Kopplung und Abschlussbestätigung. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.css new file mode 100644 index 0000000..305e62a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.css @@ -0,0 +1,455 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.js new file mode 100644 index 0000000..4604f7a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/content.js @@ -0,0 +1,1453 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
+ + + ... + + + + +
+
+ + + +
+
+ + + 0 ausgewählt +
+
+
+
+
Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
+
0offen
+
+
    +
    +
    +
      +
      +
      +
      +
      +
      Batch-Modus
      + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
      +
      +
      Pause zwischen Artikeln (Sek.)
      + +
      +
      +
      Server URL
      + +
      +
      + +
      +
      + +
      +
      +
      +
      +
      +
      + +
      + `; + 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 = ``; 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 = '
    • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
    • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
    • +
      + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
      +
      ${statusHtml}Vinted
      +
      ${esc(l.title || 'Ohne Titel')}
      +
      ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
      +
      ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
      +
      +
      ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
      +
      + + +
      +
    • `; + }).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) await fillListingData(listing, { includePhotos: false }); + })); + 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 = '
    • 📦Noch keine Artikel veröffentlicht.
    • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
    • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
      +
      ${esc(l.title || 'Ohne Titel')}
      +
      ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
      +
      + Erledigt +
    • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + `; + 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-detail').addEventListener('click', () => showDetail(id)); + actions.querySelector('#al-selection-more').addEventListener('click', () => actions.querySelector('#al-selection-menu').classList.toggle('hidden')); + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
      + + +
      + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
      + Bereit +

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

      + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
      Kategorie
      ${esc(listing.category || 'Nicht gesetzt')}
      Marke
      ${esc(listing.brand || '–')}
      Größe
      ${esc(listing.size || '–')}
      Zustand
      ${esc(listing.condition || '–')}
      +
      +
      +
      ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
      + +
      +

      Veröffentlichen auf Vinted

      +
      +
      1. 1Daten prüfenTitel, Preis, Kategorie, Größe
      2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
      3. 3Beschreibung übernehmenBeschreibung und Attribute
      4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
      5. 5VeröffentlichungListing abschließen
      +
      Auto-Fill · Vinted

      Vendoo übernimmt alle vorhandenen Listing-Daten.

      • ✓ Titel
      • ✓ Preis
      • ✓ Kategorie
      • ✓ Größe & Marke
      • ✓ Beschreibung
      • ✓ Bilder
      ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
      +
      +
      + +
      Listing-Daten prüfen & bearbeiten +
      +
      Titel
      + +
      +
      +
      Beschreibung
      + +
      +
      +
      +
      Preis
      + +
      +
      +
      Zustand
      + +
      +
      +
      +
      +
      Marke
      + +
      +
      +
      Größe
      + +
      +
      +
      +
      Kategorie (Vinted)
      +
      + +
      +
      +
      +
      +
      +
      +
      Farbe
      + +
      +
      +
      AI-Kategorie (Original)
      + +
      +
      +
      +
      Tags
      + +
      + +
      + + +
      + + +

      Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

      +
      + +
      + Entwurf speichern oder veröffentlichen +
      + + + + +
      +
      + +
      + `; + + 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 = '
      Keine Kategorie gefunden
      '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
      +
      ${esc(m.name)}
      +
      ${esc(m.path.slice(0, -1).join(' > '))}
      +
      + `).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 = '
      '; + for (const main of VINTED_CATEGORIES) { + html += `
      `; + html += `
      ${esc(main.name)}
      `; + html += `
      `; + if (main.children) { + for (const sub of main.children) { + html += `
      `; + html += `
      ${esc(sub.name)}
      `; + html += `
      `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
      ${esc(leaf.name)}
      `; + } + } + html += '
      '; + } + } + html += '
      '; + } + html += '
      '; + 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 = ` +
      +
      +
      ${esc(cat.name)}
      +
      ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
      +
      + +
      + `; + 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 = ` +
      +
      + Modus: ${modeText} +
      +
      +
      +
      + 0 / ${STATE.batchProgress.total} + 0% +
      +
      + +
      +
      + `; + 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 += `
      • ${esc(title.substring(0, 30))}... ${step}
      `; + 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 += `
      ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
      `; + 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(); + const pending = await storageGet(['pendingFillListingId']); + if (pending.pendingFillListingId) { + const listing = STATE.queue.find(item => item.id === Number(pending.pendingFillListingId)); + if (listing) { + await storageSet({ pendingFillListingId: null }); + await fillListingData(listing, { includePhotos: false }); + showDetail(listing.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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.css new file mode 100644 index 0000000..909cf8c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.css @@ -0,0 +1 @@ +:root{--bg:#f4f0e9;--surface:#fffdf9;--surface-2:#f8f5ef;--line:#ddd6cb;--ink:#22201d;--muted:#7b746b;--brand:#e63b17;--brand-dark:#c92f0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#bd4339}*{box-sizing:border-box}body{width:420px;min-height:610px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.popup-shell{min-height:610px;display:grid;grid-template-rows:auto 1fr auto;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.055),transparent 38%),var(--bg)}.popup-header{height:68px;display:flex;align-items:center;gap:10px;padding:0 17px;border-bottom:1px solid var(--line);background:rgba(255,253,249,.97)}.brand-lockup{display:flex;align-items:center;gap:10px;min-width:0}.brand-lockup img{width:36px;height:36px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:15px;letter-spacing:-.035em}.brand-lockup span{font-size:9px;color:var(--muted)}.connection-chip{margin-left:auto;min-height:25px;display:flex;align-items:center;gap:6px;padding:0 9px;border-radius:999px;background:#eee9e1;color:var(--muted);font-size:9px;font-weight:800}.connection-chip i{width:7px;height:7px;border-radius:50%;background:#aaa}.connection-chip.ok{background:var(--success-soft);color:var(--success)}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:rgba(189,67,57,.1);color:var(--danger)}.connection-chip.error i{background:var(--danger)}.icon-button{width:32px;height:32px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--muted);font-size:15px;cursor:pointer}.icon-button:hover{border-color:var(--line);background:var(--surface-2);color:var(--ink)}.popup-content,.settings-view{padding:15px 17px 18px;overflow:auto}.queue-card,.listing-card,.empty-card{border:1px solid var(--line);border-radius:15px;background:var(--surface);box-shadow:0 10px 28px rgba(42,36,29,.055)}.queue-card{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:11px;align-items:center;padding:14px}.queue-icon{width:36px;height:36px;display:grid;place-items:center;border-radius:11px;background:var(--brand-soft);color:var(--brand);font-size:17px;font-weight:900}.queue-card span{display:block;color:var(--brand);font-size:8px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}.queue-card strong{display:block;margin-top:3px;font-size:12px}.queue-card small{display:block;margin-top:2px;color:var(--muted);font-size:8px}.queue-progress{height:5px;margin-top:9px;overflow:hidden;border-radius:999px;background:#e9e3da}.queue-progress i{display:block;width:0;height:100%;border-radius:inherit;background:var(--brand);transition:width .25s ease}.queue-count{display:grid;justify-items:end;gap:7px}.queue-count b{font-size:11px}.queue-count button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.listing-card{margin-top:12px;padding:12px}.listing-card>header{display:flex;align-items:center;justify-content:space-between;padding:2px 2px 11px}.listing-card>header>div{display:flex;align-items:center;gap:7px}.listing-card>header span{font-size:11px;font-weight:850}.listing-card>header b{padding:3px 7px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:8px}.listing-card>header button{border:0;background:transparent;color:var(--muted);font-size:14px}.listing-overview{display:grid;grid-template-columns:128px minmax(0,1fr);gap:12px}.listing-photo{height:166px;display:grid;place-items:center;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#fff,#eee9e1)}.listing-photo img{width:100%;height:100%;object-fit:contain;padding:7px}.listing-copy{min-width:0}.listing-copy h2{margin:2px 0 5px;font-size:13px;line-height:1.32;letter-spacing:-.025em}.listing-copy p{margin:0;color:var(--muted);font-size:9px}.listing-copy code{display:block;margin:7px 0 11px;color:var(--muted);font-size:8px}.listing-copy dl{display:grid;grid-template-columns:62px minmax(0,1fr);gap:7px 5px;margin:0;font-size:9px}.listing-copy dt{color:var(--muted)}.listing-copy dd{margin:0;font-weight:700}.platform-chip{display:inline-flex;padding:2px 6px;border-radius:6px;background:#eee9e1;font-size:8px}.safe-notice{display:grid;gap:2px;margin:12px 0 3px;padding:10px 11px;border:1px solid rgba(33,131,86,.18);border-radius:10px;background:var(--success-soft)}.safe-notice b{color:var(--success);font-size:9px}.safe-notice span{color:var(--muted);font-size:8px;line-height:1.4}.primary-action,.secondary-action{min-height:42px;border-radius:10px;font:inherit;font-size:10px;font-weight:850;cursor:pointer}.primary-action{width:100%;display:flex;justify-content:center;align-items:center;gap:8px;border:0;background:var(--brand);color:#fff}.primary-action:hover{background:var(--brand-dark)}.primary-action span{margin-left:auto;padding-right:5px}.action-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.secondary-action{width:100%;border:1px solid var(--line);background:var(--surface);color:var(--ink)}.secondary-action:hover{border-color:var(--brand);color:var(--brand)}.empty-card{padding:38px 22px;text-align:center}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:10px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:10px;font-weight:850}.settings-view{display:grid;align-content:start;gap:12px}.settings-heading{display:flex;align-items:center;gap:9px;margin-bottom:3px}.settings-heading button{width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:9px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:9px;font-weight:800}.setting input,.setting select{width:100%;height:42px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}footer{min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 17px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:8px}footer button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.hidden{display:none!important} \ No newline at end of file diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.html new file mode 100644 index 0000000..8b90ac1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.html @@ -0,0 +1,49 @@ + + + + + + Vendoo Link + + + +
      + + + + + + +
      Vendoo Link v1.9.0
      +
      + + + \ No newline at end of file diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.js new file mode 100644 index 0000000..8fbde6b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/popup.js @@ -0,0 +1,129 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], current: 0, serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatDate(value) { + const date = new Date(String(value || '').replace(' ', 'T') + 'Z'); + return Number.isNaN(date.getTime()) ? '–' : date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +async function renderCurrent() { + const listing = state.queue[state.current]; + $('#listing-card').classList.toggle('hidden', !listing); + $('#empty-card').classList.toggle('hidden', !!listing); + if (!listing) return; + $('#listing-title').textContent = listing.title || 'Ohne Titel'; + $('#listing-meta').textContent = [listing.size, listing.color, listing.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften'; + $('#listing-sku').textContent = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + $('#listing-price').textContent = listing.price ? `${Number(listing.price).toFixed(2).replace('.', ',')} €` : '–'; + $('#listing-created').textContent = formatDate(listing.created_at); + const src = await imageData(listing.photos?.[0]); + const image = $('#listing-image'); + if (src) { image.src = src; image.classList.remove('hidden'); } else { image.removeAttribute('src'); image.classList.add('hidden'); } +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + state.current = Math.min(state.current, Math.max(0, state.queue.length - 1)); + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zur Übertragung' : 'Queue leer'; + $('#queue-count').textContent = `${state.queue.length} / ${data.total || state.queue.length}`; + $('#queue-progress-fill').style.width = `${data.total ? ((data.total - state.queue.length) / data.total) * 100 : 0}%`; + await renderCurrent(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = error.message; + $('#listing-card').classList.add('hidden'); + $('#empty-card').classList.remove('hidden'); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +function openUrl(url) { openTab(url); } +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#next-listing').addEventListener('click', async () => { if (state.queue.length) state.current = (state.current + 1) % state.queue.length; await renderCurrent(); }); +$('#fill-listing').addEventListener('click', async () => { + const listing = state.queue[state.current]; + if (!listing) return; + await storageSet({ pendingFillListingId: listing.id }); + openUrl('https://www.vinted.de/items/new'); + window.close(); +}); + +$('#prepare-images').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-queue').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openUrl(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openUrl(state.serverUrl)); + +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extension/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/README.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/README.md new file mode 100644 index 0000000..e49e2a1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/README.md @@ -0,0 +1,38 @@ +# Vendoo Browser Extensions + +- `chrome/` – Chrome, Manifest V3 +- `edge/` – Microsoft Edge, Manifest V3 +- `firefox/` – Firefox, WebExtension-Paket + +## Lokal laden + +### Chrome +`chrome://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/chrome` auswählen. + +### Edge +`edge://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/edge` auswählen. + +### Firefox +`about:debugging#/runtime/this-firefox` öffnen → **Temporäres Add-on laden** → `extensions/firefox/manifest.json` auswählen. + +Die Serveradresse kann im Popup geändert werden. Für öffentliche Server muss die Vendoo-Adresse per HTTPS erreichbar sein. + +## Serverzugriff + +Die Erweiterung kann mit `localhost`, einer LAN-Adresse oder einer öffentlichen HTTPS-Adresse verbunden werden. Da die Serveradresse frei konfigurierbar ist, verlangen die Browser beim Laden Zugriff auf HTTP-/HTTPS-Seiten. Die Erweiterung verwendet diesen Zugriff ausschließlich für die eingetragene Vendoo-Instanz und die unterstützten Vinted-Seiten. + +## Alte Erweiterung ersetzen + +Der frühere Ordner `extension/` wird nicht mehr verwendet. Eine bereits geladene Altversion zuerst entfernen und danach den passenden Ordner unter `extensions/` laden. + +## Hotfix 1.9.0 + +Die Feldsuche der Vinted-Erweiterung verwendet keine unspezifischen Elterncontainer mehr. Nach einem Vendoo-Update muss die entpackt geladene Erweiterung in `chrome://extensions` bzw. `edge://extensions` über **Neu laden** aktualisiert und die geöffnete Vinted-Seite einmal neu geladen werden. + + +## Version 1.9.0 / Safari + +Vendoo enthält jetzt eine zentrale Installationsseite und eine Safari-WebExtension-Quelle. Safari benötigt macOS und Xcode zur Verpackung und Signierung. + +## Version 1.9.0 – Publishing Hardening +Die Extension arbeitet mit der gehärteten Publishing-Queue zusammen. Bestätigte Veröffentlichungen schließen zugehörige Extension-Aufträge automatisch ab. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
      + + + ... + + + + +
      +
      + + + +
      +
      + + + 0 ausgewählt +
      +
      +
      +
      +
      Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
      +
      0offen
      +
      +
        +
        +
        +
          +
          +
          +
          +
          +
          Batch-Modus
          + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
          +
          +
          Pause zwischen Artikeln (Sek.)
          + +
          +
          +
          Server URL
          + +
          +
          + +
          +
          + +
          +
          +
          +
          +
          +
          + +
          + `; + 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 = ``; 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 = '
        • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
        • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
        • +
          + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
          +
          ${statusHtml}Vinted
          +
          ${esc(l.title || 'Ohne Titel')}
          +
          ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
          +
          ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
          +
          +
          ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
          +
          + + + +
          +
        • `; + }).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 = '
        • 📦Noch keine Artikel veröffentlicht.
        • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
        • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
          +
          ${esc(l.title || 'Ohne Titel')}
          +
          ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
          +
          + Erledigt +
        • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
          + + +
          + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
          + Bereit +

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

          + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
          Kategorie
          ${esc(listing.category || 'Nicht gesetzt')}
          Marke
          ${esc(listing.brand || '–')}
          Größe
          ${esc(listing.size || '–')}
          Zustand
          ${esc(listing.condition || '–')}
          +
          +
          +
          ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
          + +
          +

          Veröffentlichen auf Vinted

          +
          +
          1. 1Daten prüfenTitel, Preis, Kategorie, Größe
          2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
          3. 3Beschreibung übernehmenBeschreibung und Attribute
          4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
          5. 5VeröffentlichungListing abschließen
          +
          Auto-Fill · Vinted

          Vendoo übernimmt alle vorhandenen Listing-Daten.

          • ✓ Titel
          • ✓ Preis
          • ✓ Kategorie
          • ✓ Größe & Marke
          • ✓ Beschreibung
          • ✓ Bilder
          ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
          +
          +
          + +
          Listing-Daten prüfen & bearbeiten +
          +
          Titel
          + +
          +
          +
          Beschreibung
          + +
          +
          +
          +
          Preis
          + +
          +
          +
          Zustand
          + +
          +
          +
          +
          +
          Marke
          + +
          +
          +
          Größe
          + +
          +
          +
          +
          Kategorie (Vinted)
          +
          + +
          +
          +
          +
          +
          +
          +
          Farbe
          + +
          +
          +
          AI-Kategorie (Original)
          + +
          +
          +
          +
          Tags
          + +
          + +
          + + +
          + + +

          Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

          +
          + +
          + Entwurf speichern oder veröffentlichen +
          + + + + +
          +
          + +
          + `; + + 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 = '
          Keine Kategorie gefunden
          '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
          +
          ${esc(m.name)}
          +
          ${esc(m.path.slice(0, -1).join(' > '))}
          +
          + `).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 = '
          '; + for (const main of VINTED_CATEGORIES) { + html += `
          `; + html += `
          ${esc(main.name)}
          `; + html += `
          `; + if (main.children) { + for (const sub of main.children) { + html += `
          `; + html += `
          ${esc(sub.name)}
          `; + html += `
          `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
          ${esc(leaf.name)}
          `; + } + } + html += '
          '; + } + } + html += '
          '; + } + html += '
          '; + 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 = ` +
          +
          +
          ${esc(cat.name)}
          +
          ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
          +
          + +
          + `; + 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 = ` +
          +
          + Modus: ${modeText} +
          +
          +
          +
          + 0 / ${STATE.batchProgress.total} + 0% +
          +
          + +
          +
          + `; + 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 += `
          • ${esc(title.substring(0, 30))}... ${step}
          `; + 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 += `
          ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
          `; + 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(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
          + + + + + + +
          + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
          +
          + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
          +
          ${escapeHtml(item.title || 'Produktbild')}
          +
          +
          +
          ${escapeHtml(item.title || 'Ohne Titel')}
          +
          ${formatPrice(item.price)}
          +
          +
          ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
          +
          ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
          +
          + + + + +
          +
          +
          + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/chrome/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
          + + + ... + + + + +
          +
          + + + +
          +
          + + + 0 ausgewählt +
          +
          +
          +
          +
          Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
          +
          0offen
          +
          +
            +
            +
            +
              +
              +
              +
              +
              +
              Batch-Modus
              + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
              +
              +
              Pause zwischen Artikeln (Sek.)
              + +
              +
              +
              Server URL
              + +
              +
              + +
              +
              + +
              +
              +
              +
              +
              +
              + +
              + `; + 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 = ``; 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 = '
            • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
            • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
            • +
              + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
              +
              ${statusHtml}Vinted
              +
              ${esc(l.title || 'Ohne Titel')}
              +
              ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
              +
              ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
              +
              +
              ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
              +
              + + + +
              +
            • `; + }).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 = '
            • 📦Noch keine Artikel veröffentlicht.
            • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
            • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
              +
              ${esc(l.title || 'Ohne Titel')}
              +
              ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
              +
              + Erledigt +
            • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
              + + +
              + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
              + Bereit +

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

              + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
              Kategorie
              ${esc(listing.category || 'Nicht gesetzt')}
              Marke
              ${esc(listing.brand || '–')}
              Größe
              ${esc(listing.size || '–')}
              Zustand
              ${esc(listing.condition || '–')}
              +
              +
              +
              ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
              + +
              +

              Veröffentlichen auf Vinted

              +
              +
              1. 1Daten prüfenTitel, Preis, Kategorie, Größe
              2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
              3. 3Beschreibung übernehmenBeschreibung und Attribute
              4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
              5. 5VeröffentlichungListing abschließen
              +
              Auto-Fill · Vinted

              Vendoo übernimmt alle vorhandenen Listing-Daten.

              • ✓ Titel
              • ✓ Preis
              • ✓ Kategorie
              • ✓ Größe & Marke
              • ✓ Beschreibung
              • ✓ Bilder
              ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
              +
              +
              + +
              Listing-Daten prüfen & bearbeiten +
              +
              Titel
              + +
              +
              +
              Beschreibung
              + +
              +
              +
              +
              Preis
              + +
              +
              +
              Zustand
              + +
              +
              +
              +
              +
              Marke
              + +
              +
              +
              Größe
              + +
              +
              +
              +
              Kategorie (Vinted)
              +
              + +
              +
              +
              +
              +
              +
              +
              Farbe
              + +
              +
              +
              AI-Kategorie (Original)
              + +
              +
              +
              +
              Tags
              + +
              + +
              + + +
              + + +

              Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

              +
              + +
              + Entwurf speichern oder veröffentlichen +
              + + + + +
              +
              + +
              + `; + + 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 = '
              Keine Kategorie gefunden
              '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
              +
              ${esc(m.name)}
              +
              ${esc(m.path.slice(0, -1).join(' > '))}
              +
              + `).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 = '
              '; + for (const main of VINTED_CATEGORIES) { + html += `
              `; + html += `
              ${esc(main.name)}
              `; + html += `
              `; + if (main.children) { + for (const sub of main.children) { + html += `
              `; + html += `
              ${esc(sub.name)}
              `; + html += `
              `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
              ${esc(leaf.name)}
              `; + } + } + html += '
              '; + } + } + html += '
              '; + } + html += '
              '; + 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 = ` +
              +
              +
              ${esc(cat.name)}
              +
              ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
              +
              + +
              + `; + 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 = ` +
              +
              + Modus: ${modeText} +
              +
              +
              +
              + 0 / ${STATE.batchProgress.total} + 0% +
              +
              + +
              +
              + `; + 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 += `
              • ${esc(title.substring(0, 30))}... ${step}
              `; + 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 += `
              ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
              `; + 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(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
              + + + ... + + + + +
              +
              + + + +
              +
              + + + 0 ausgewählt +
              +
              +
              +
              +
              Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
              +
              0offen
              +
              +
                +
                +
                +
                  +
                  +
                  +
                  +
                  +
                  Batch-Modus
                  + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                  +
                  +
                  Pause zwischen Artikeln (Sek.)
                  + +
                  +
                  +
                  Server URL
                  + +
                  +
                  + +
                  +
                  + +
                  +
                  +
                  +
                  +
                  +
                  + +
                  + `; + 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 = ``; 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 = '
                • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                • +
                  + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                  +
                  ${statusHtml}Vinted
                  +
                  ${esc(l.title || 'Ohne Titel')}
                  +
                  ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                  +
                  ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                  +
                  +
                  ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                  +
                  + + + +
                  +
                • `; + }).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 = '
                • 📦Noch keine Artikel veröffentlicht.
                • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                  +
                  ${esc(l.title || 'Ohne Titel')}
                  +
                  ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                  +
                  + Erledigt +
                • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                  + + +
                  + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                  + Bereit +

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

                  + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                  Kategorie
                  ${esc(listing.category || 'Nicht gesetzt')}
                  Marke
                  ${esc(listing.brand || '–')}
                  Größe
                  ${esc(listing.size || '–')}
                  Zustand
                  ${esc(listing.condition || '–')}
                  +
                  +
                  +
                  ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                  + +
                  +

                  Veröffentlichen auf Vinted

                  +
                  +
                  1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                  2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                  3. 3Beschreibung übernehmenBeschreibung und Attribute
                  4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                  5. 5VeröffentlichungListing abschließen
                  +
                  Auto-Fill · Vinted

                  Vendoo übernimmt alle vorhandenen Listing-Daten.

                  • ✓ Titel
                  • ✓ Preis
                  • ✓ Kategorie
                  • ✓ Größe & Marke
                  • ✓ Beschreibung
                  • ✓ Bilder
                  ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                  +
                  +
                  + +
                  Listing-Daten prüfen & bearbeiten +
                  +
                  Titel
                  + +
                  +
                  +
                  Beschreibung
                  + +
                  +
                  +
                  +
                  Preis
                  + +
                  +
                  +
                  Zustand
                  + +
                  +
                  +
                  +
                  +
                  Marke
                  + +
                  +
                  +
                  Größe
                  + +
                  +
                  +
                  +
                  Kategorie (Vinted)
                  +
                  + +
                  +
                  +
                  +
                  +
                  +
                  +
                  Farbe
                  + +
                  +
                  +
                  AI-Kategorie (Original)
                  + +
                  +
                  +
                  +
                  Tags
                  + +
                  + +
                  + + +
                  + + +

                  Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                  +
                  + +
                  + Entwurf speichern oder veröffentlichen +
                  + + + + +
                  +
                  + +
                  + `; + + 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 = '
                  Keine Kategorie gefunden
                  '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                  +
                  ${esc(m.name)}
                  +
                  ${esc(m.path.slice(0, -1).join(' > '))}
                  +
                  + `).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 = '
                  '; + for (const main of VINTED_CATEGORIES) { + html += `
                  `; + html += `
                  ${esc(main.name)}
                  `; + html += `
                  `; + if (main.children) { + for (const sub of main.children) { + html += `
                  `; + html += `
                  ${esc(sub.name)}
                  `; + html += `
                  `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                  ${esc(leaf.name)}
                  `; + } + } + html += '
                  '; + } + } + html += '
                  '; + } + html += '
                  '; + 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 = ` +
                  +
                  +
                  ${esc(cat.name)}
                  +
                  ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                  +
                  + +
                  + `; + 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 = ` +
                  +
                  + Modus: ${modeText} +
                  +
                  +
                  +
                  + 0 / ${STATE.batchProgress.total} + 0% +
                  +
                  + +
                  +
                  + `; + 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 += `
                  • ${esc(title.substring(0, 30))}... ${step}
                  `; + 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 += `
                  ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                  `; + 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(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                  + + + + + + +
                  + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                  +
                  + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                  +
                  ${escapeHtml(item.title || 'Produktbild')}
                  +
                  +
                  +
                  ${escapeHtml(item.title || 'Ohne Titel')}
                  +
                  ${formatPrice(item.price)}
                  +
                  +
                  ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                  +
                  ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                  +
                  + + + + +
                  +
                  +
                  + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/edge/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                  + + + ... + + + + +
                  +
                  + + + +
                  +
                  + + + 0 ausgewählt +
                  +
                  +
                  +
                  +
                  Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                  +
                  0offen
                  +
                  +
                    +
                    +
                    +
                      +
                      +
                      +
                      +
                      +
                      Batch-Modus
                      + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                      +
                      +
                      Pause zwischen Artikeln (Sek.)
                      + +
                      +
                      +
                      Server URL
                      + +
                      +
                      + +
                      +
                      + +
                      +
                      +
                      +
                      +
                      +
                      + +
                      + `; + 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 = ``; 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 = '
                    • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                    • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                    • +
                      + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                      +
                      ${statusHtml}Vinted
                      +
                      ${esc(l.title || 'Ohne Titel')}
                      +
                      ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                      +
                      ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                      +
                      +
                      ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                      +
                      + + + +
                      +
                    • `; + }).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 = '
                    • 📦Noch keine Artikel veröffentlicht.
                    • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                    • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                      +
                      ${esc(l.title || 'Ohne Titel')}
                      +
                      ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                      +
                      + Erledigt +
                    • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                      + + +
                      + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                      + Bereit +

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

                      + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                      Kategorie
                      ${esc(listing.category || 'Nicht gesetzt')}
                      Marke
                      ${esc(listing.brand || '–')}
                      Größe
                      ${esc(listing.size || '–')}
                      Zustand
                      ${esc(listing.condition || '–')}
                      +
                      +
                      +
                      ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                      + +
                      +

                      Veröffentlichen auf Vinted

                      +
                      +
                      1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                      2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                      3. 3Beschreibung übernehmenBeschreibung und Attribute
                      4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                      5. 5VeröffentlichungListing abschließen
                      +
                      Auto-Fill · Vinted

                      Vendoo übernimmt alle vorhandenen Listing-Daten.

                      • ✓ Titel
                      • ✓ Preis
                      • ✓ Kategorie
                      • ✓ Größe & Marke
                      • ✓ Beschreibung
                      • ✓ Bilder
                      ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                      +
                      +
                      + +
                      Listing-Daten prüfen & bearbeiten +
                      +
                      Titel
                      + +
                      +
                      +
                      Beschreibung
                      + +
                      +
                      +
                      +
                      Preis
                      + +
                      +
                      +
                      Zustand
                      + +
                      +
                      +
                      +
                      +
                      Marke
                      + +
                      +
                      +
                      Größe
                      + +
                      +
                      +
                      +
                      Kategorie (Vinted)
                      +
                      + +
                      +
                      +
                      +
                      +
                      +
                      +
                      Farbe
                      + +
                      +
                      +
                      AI-Kategorie (Original)
                      + +
                      +
                      +
                      +
                      Tags
                      + +
                      + +
                      + + +
                      + + +

                      Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                      +
                      + +
                      + Entwurf speichern oder veröffentlichen +
                      + + + + +
                      +
                      + +
                      + `; + + 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 = '
                      Keine Kategorie gefunden
                      '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                      +
                      ${esc(m.name)}
                      +
                      ${esc(m.path.slice(0, -1).join(' > '))}
                      +
                      + `).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 = '
                      '; + for (const main of VINTED_CATEGORIES) { + html += `
                      `; + html += `
                      ${esc(main.name)}
                      `; + html += `
                      `; + if (main.children) { + for (const sub of main.children) { + html += `
                      `; + html += `
                      ${esc(sub.name)}
                      `; + html += `
                      `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                      ${esc(leaf.name)}
                      `; + } + } + html += '
                      '; + } + } + html += '
                      '; + } + html += '
                      '; + 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 = ` +
                      +
                      +
                      ${esc(cat.name)}
                      +
                      ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                      +
                      + +
                      + `; + 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 = ` +
                      +
                      + Modus: ${modeText} +
                      +
                      +
                      +
                      + 0 / ${STATE.batchProgress.total} + 0% +
                      +
                      + +
                      +
                      + `; + 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 += `
                      • ${esc(title.substring(0, 30))}... ${step}
                      `; + 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 += `
                      ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                      `; + 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(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/manifest.json new file mode 100644 index 0000000..69e18c4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/manifest.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                      + + + + + + +
                      + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                      +
                      + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                      +
                      ${escapeHtml(item.title || 'Produktbild')}
                      +
                      +
                      +
                      ${escapeHtml(item.title || 'Ohne Titel')}
                      +
                      ${formatPrice(item.price)}
                      +
                      +
                      ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                      +
                      ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                      +
                      + + + + +
                      +
                      +
                      + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/firefox/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.chromium.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.chromium.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.chromium.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.firefox.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.firefox.json new file mode 100644 index 0000000..69e18c4 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/manifest.firefox.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-chrome-1.9.0.zip b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-chrome-1.9.0.zip new file mode 100644 index 0000000..b0a83c5 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-chrome-1.9.0.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-edge-1.9.0.zip b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-edge-1.9.0.zip new file mode 100644 index 0000000..b0a83c5 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-edge-1.9.0.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-firefox-1.9.0.zip b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-firefox-1.9.0.zip new file mode 100644 index 0000000..54a16f4 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-firefox-1.9.0.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-safari-1.9.0.zip b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-safari-1.9.0.zip new file mode 100644 index 0000000..0f701e7 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/packages/vendoo-link-safari-1.9.0.zip differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                      + + + + + + +
                      + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                      +
                      + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                      +
                      ${escapeHtml(item.title || 'Produktbild')}
                      +
                      +
                      +
                      ${escapeHtml(item.title || 'Ohne Titel')}
                      +
                      ${formatPrice(item.price)}
                      +
                      +
                      ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                      +
                      ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                      +
                      + + + + +
                      +
                      +
                      + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/README_SAFARI.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/README_SAFARI.md new file mode 100644 index 0000000..d6ac48d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/README_SAFARI.md @@ -0,0 +1,13 @@ +# Vendoo Link für Safari + +Safari-WebExtensions müssen von Apple als macOS-App verpackt und signiert werden. Die Quelle liegt in `web-extension`. + +## Erstellen + +1. Auf einem Mac Xcode installieren. +2. `build-safari-extension.command` per Doppelklick starten. +3. Das erzeugte Xcode-Projekt öffnen, ein lokales Development Team auswählen und die App starten. +4. Safari > Einstellungen > Erweitert: Entwicklerfunktionen aktivieren. +5. Safari > Einstellungen > Erweiterungen: Vendoo Link aktivieren. + +Eine stille automatische Installation ist bei Safari nicht möglich. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/build-safari-extension.command b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/build-safari-extension.command new file mode 100644 index 0000000..b79e0d2 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/build-safari-extension.command @@ -0,0 +1,16 @@ +#!/bin/zsh +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if ! command -v xcrun >/dev/null 2>&1; then + echo "Xcode Command Line Tools fehlen. Bitte Xcode installieren." + read -k 1 + exit 1 +fi +OUT="$SCRIPT_DIR/build" +mkdir -p "$OUT" +xcrun safari-web-extension-converter "$SCRIPT_DIR/web-extension" \ + --project-location "$OUT" \ + --app-name "Vendoo Link" \ + --bundle-identifier "local.vendoo.link" \ + --copy-resources +open "$OUT" diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/background.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                      + + + ... + + + + +
                      +
                      + + + +
                      +
                      + + + 0 ausgewählt +
                      +
                      +
                      +
                      +
                      Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                      +
                      0offen
                      +
                      +
                        +
                        +
                        +
                          +
                          +
                          +
                          +
                          +
                          Batch-Modus
                          + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                          +
                          +
                          Pause zwischen Artikeln (Sek.)
                          + +
                          +
                          +
                          Server URL
                          + +
                          +
                          + +
                          +
                          + +
                          +
                          +
                          +
                          +
                          +
                          + +
                          + `; + 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 = ``; 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 = '
                        • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                        • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                        • +
                          + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                          +
                          ${statusHtml}Vinted
                          +
                          ${esc(l.title || 'Ohne Titel')}
                          +
                          ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                          +
                          ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                          +
                          +
                          ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                          +
                          + + + +
                          +
                        • `; + }).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 = '
                        • 📦Noch keine Artikel veröffentlicht.
                        • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                        • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                          +
                          ${esc(l.title || 'Ohne Titel')}
                          +
                          ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                          +
                          + Erledigt +
                        • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                          + + +
                          + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                          + Bereit +

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

                          + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                          Kategorie
                          ${esc(listing.category || 'Nicht gesetzt')}
                          Marke
                          ${esc(listing.brand || '–')}
                          Größe
                          ${esc(listing.size || '–')}
                          Zustand
                          ${esc(listing.condition || '–')}
                          +
                          +
                          +
                          ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                          + +
                          +

                          Veröffentlichen auf Vinted

                          +
                          +
                          1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                          2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                          3. 3Beschreibung übernehmenBeschreibung und Attribute
                          4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                          5. 5VeröffentlichungListing abschließen
                          +
                          Auto-Fill · Vinted

                          Vendoo übernimmt alle vorhandenen Listing-Daten.

                          • ✓ Titel
                          • ✓ Preis
                          • ✓ Kategorie
                          • ✓ Größe & Marke
                          • ✓ Beschreibung
                          • ✓ Bilder
                          ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                          +
                          +
                          + +
                          Listing-Daten prüfen & bearbeiten +
                          +
                          Titel
                          + +
                          +
                          +
                          Beschreibung
                          + +
                          +
                          +
                          +
                          Preis
                          + +
                          +
                          +
                          Zustand
                          + +
                          +
                          +
                          +
                          +
                          Marke
                          + +
                          +
                          +
                          Größe
                          + +
                          +
                          +
                          +
                          Kategorie (Vinted)
                          +
                          + +
                          +
                          +
                          +
                          +
                          +
                          +
                          Farbe
                          + +
                          +
                          +
                          AI-Kategorie (Original)
                          + +
                          +
                          +
                          +
                          Tags
                          + +
                          + +
                          + + +
                          + + +

                          Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                          +
                          + +
                          + Entwurf speichern oder veröffentlichen +
                          + + + + +
                          +
                          + +
                          + `; + + 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 = '
                          Keine Kategorie gefunden
                          '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                          +
                          ${esc(m.name)}
                          +
                          ${esc(m.path.slice(0, -1).join(' > '))}
                          +
                          + `).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 = '
                          '; + for (const main of VINTED_CATEGORIES) { + html += `
                          `; + html += `
                          ${esc(main.name)}
                          `; + html += `
                          `; + if (main.children) { + for (const sub of main.children) { + html += `
                          `; + html += `
                          ${esc(sub.name)}
                          `; + html += `
                          `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                          ${esc(leaf.name)}
                          `; + } + } + html += '
                          '; + } + } + html += '
                          '; + } + html += '
                          '; + 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 = ` +
                          +
                          +
                          ${esc(cat.name)}
                          +
                          ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                          +
                          + +
                          + `; + 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 = ` +
                          +
                          + Modus: ${modeText} +
                          +
                          +
                          +
                          + 0 / ${STATE.batchProgress.total} + 0% +
                          +
                          + +
                          +
                          + `; + 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 += `
                          • ${esc(title.substring(0, 30))}... ${step}
                          `; + 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 += `
                          ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                          `; + 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(); +})(); + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon-48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon.svg b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/manifest.json new file mode 100644 index 0000000..bb47c7c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Safari Assistant", + "version": "1.9.0", + "description": "Vendoo Link als Safari Web Extension mit Vinted-Auto-Fill, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                          + + + + + + +
                          + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                          +
                          + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                          +
                          ${escapeHtml(item.title || 'Produktbild')}
                          +
                          +
                          +
                          ${escapeHtml(item.title || 'Ohne Titel')}
                          +
                          ${formatPrice(item.price)}
                          +
                          +
                          ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                          +
                          ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                          +
                          + + + + +
                          +
                          +
                          + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/safari/web-extension/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/vinted-categories.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/extensions/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-claude.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-claude.mjs new file mode 100644 index 0000000..65c9401 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-claude.mjs @@ -0,0 +1,41 @@ +import Anthropic from '@anthropic-ai/sdk'; + +let client; + +function getClient() { + if (!client) client = new Anthropic(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image', + source: { type: 'base64', media_type: img.mediaType, data: img.base64 }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1500, + system: systemPrompt, + messages: [{ role: 'user', content }], + }); + + const text = response.content[0].text; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt) { + const response = await getClient().messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1400, + system: systemPrompt, + messages: [{ role: 'user', content: String(userPrompt || '') }], + }); + return response.content?.map(part => part.type === 'text' ? part.text : '').join('\n').trim(); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-flux-prompt-jobs.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-flux-prompt-jobs.mjs new file mode 100644 index 0000000..25bbf6c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-flux-prompt-jobs.mjs @@ -0,0 +1,612 @@ +import { join } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { mkdirSync, writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; +import { + generateWithLocalFlux, + waitForLocalFluxPrompt, + cancelLocalFluxPrompt, +} from './ai-local-images.mjs'; +import { db, createFluxImage, getSettings } from './db.mjs'; + +const outputDir = join(UPLOADS_DIR, 'ai-generated'); +mkdirSync(outputDir, { recursive: true }); + +const activeVariants = new Map(); +let loopTimer = null; +let loopBusy = false; + +const STYLE_SUFFIXES = { + none: '', + photorealistic: 'photorealistic image, natural materials, physically plausible lighting, realistic proportions, fine surface detail, clean coherent composition', + product: 'professional commercial product photography, clear hero subject, controlled studio lighting, crisp edges, realistic materials, premium catalog composition', + editorial: 'premium editorial photography, refined magazine composition, intentional styling, controlled light, elegant color harmony, realistic detail', + cinematic: 'cinematic still, purposeful framing, atmospheric but realistic lighting, natural depth, controlled contrast, coherent visual storytelling', + illustration: 'polished high-quality digital illustration, coherent shapes, intentional line and color treatment, refined detail, professional composition', + social: 'modern social media visual, immediately readable focal point, clean hierarchy, polished lighting, balanced composition, premium finish', +}; + +const PROFILE_CONFIG = { + fast: { maxEdge: 768, steps: 4 }, + balanced: { maxEdge: 1024, steps: 4 }, + quality: { maxEdge: 1024, steps: 6 }, + custom: { maxEdge: 1536, steps: null }, +}; + +const MODE_SUFFIXES = { + raw: '', + precise: 'The main subject must be unambiguous and visually dominant. Keep all requested objects, positions, materials, colors, perspective and environment consistent.', + creative: 'Create a visually distinctive but coherent interpretation while preserving the requested subject, purpose and essential attributes.', +}; + +const TERMINAL_VARIANT_STATUSES = new Set(['completed', 'failed', 'cancelled']); +const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']); + +function normalizeWhitespace(value) { + return String(value || '').replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').trim(); +} + +function removeNegativePromptPatterns(value) { + return normalizeWhitespace(value) + .replace(/\bwithout\s+([\w-]+)/gi, 'avoiding visible $1') + .replace(/\bohne\s+([\wäöüß-]+)/gi, 'mit einer Gestaltung, in der $1 nicht im Fokus steht'); +} + +export function buildFluxPrompt(prompt, { style = 'none', mode = 'precise', assistant = true } = {}) { + const cleaned = removeNegativePromptPatterns(prompt); + if (!assistant) return [cleaned, STYLE_SUFFIXES[style] || ''].filter(Boolean).join('. '); + const sections = [cleaned]; + if (MODE_SUFFIXES[mode]) sections.push(MODE_SUFFIXES[mode]); + if (STYLE_SUFFIXES[style]) sections.push(STYLE_SUFFIXES[style]); + sections.push('single coherent scene, accurate anatomy and geometry where applicable, no unintended duplicate subjects, no watermark, no interface elements'); + return sections.map(normalizeWhitespace).filter(Boolean).join('. ').replace(/\.{2,}/g, '.'); +} + +function clampDimension(value, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(256, Math.min(1536, Math.round(number / 8) * 8)); +} + +function cleanStyle(value) { + const style = String(value || 'none').toLowerCase(); + return Object.hasOwn(STYLE_SUFFIXES, style) ? style : 'none'; +} + +function cleanProfile(value) { + const profile = String(value || 'balanced').toLowerCase(); + return Object.hasOwn(PROFILE_CONFIG, profile) ? profile : 'balanced'; +} + +function cleanMode(value) { + const mode = String(value || 'precise').toLowerCase(); + return Object.hasOwn(MODE_SUFFIXES, mode) ? mode : 'precise'; +} + +function cleanVariantCount(value) { + const count = Number(value); + return [1, 2, 4].includes(count) ? count : 1; +} + +function normalizeSeed(value) { + return Number.isFinite(Number(value)) + ? Math.max(0, Math.min(2147483647, Math.round(Number(value)))) + : Math.floor(Math.random() * 2147483647); +} + +function randomSeed(excluded = new Set()) { + let seed = Math.floor(Math.random() * 2147483647); + while (excluded.has(seed)) seed = Math.floor(Math.random() * 2147483647); + return seed; +} + +function buildSeeds(baseSeed, variants, seedLock, explicitSeeds = null) { + if (Array.isArray(explicitSeeds) && explicitSeeds.length === variants) { + return explicitSeeds.map(normalizeSeed); + } + const used = new Set(); + const seeds = []; + const base = normalizeSeed(baseSeed); + for (let index = 0; index < variants; index += 1) { + let seed; + if (seedLock) seed = (base + index) % 2147483648; + else seed = index === 0 ? base : randomSeed(used); + while (used.has(seed)) seed = (seed + 1) % 2147483648; + used.add(seed); + seeds.push(seed); + } + return seeds; +} + +function applyProfile({ profile, width, height, steps }) { + const cfg = PROFILE_CONFIG[profile]; + let outWidth = clampDimension(width, 768); + let outHeight = clampDimension(height, 768); + if (profile !== 'custom') { + const edge = Math.max(outWidth, outHeight); + if (edge > cfg.maxEdge) { + const scale = cfg.maxEdge / edge; + outWidth = Math.max(256, Math.round((outWidth * scale) / 8) * 8); + outHeight = Math.max(256, Math.round((outHeight * scale) / 8) * 8); + } + } + return { + width: outWidth, + height: outHeight, + steps: cfg.steps ?? Math.max(4, Math.min(12, Math.round(Number(steps) || 4))), + }; +} + +function parseDate(value) { + if (!value) return null; + const iso = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`; + const time = Date.parse(iso); + return Number.isFinite(time) ? time : null; +} + +function getVariantRows(jobId) { + return db.prepare(`SELECT v.*, i.image_path, i.favorite, i.prompt, i.final_prompt, i.width, i.height, i.steps, i.style, i.profile, i.prompt_mode + FROM flux_prompt_job_variants v + LEFT JOIN flux_images i ON i.id = v.image_id + WHERE v.job_id = ? ORDER BY v.variant_index`).all(String(jobId)); +} + +function publicVariant(row) { + return { + id: row.id, + job_id: row.job_id, + variant_index: Number(row.variant_index), + seed: Number(row.seed), + status: row.status, + phase: row.phase, + message: row.message || '', + prompt_id: row.prompt_id || null, + image_id: row.image_id || null, + image_path: row.image_path || null, + public_url: row.image_path ? `/uploads/${row.image_path}` : null, + favorite: Number(row.favorite || 0) === 1, + prompt: row.prompt || null, + final_prompt: row.final_prompt || null, + width: row.width ? Number(row.width) : null, + height: row.height ? Number(row.height) : null, + steps: row.steps ? Number(row.steps) : null, + style: row.style || null, + profile: row.profile || null, + prompt_mode: row.prompt_mode || null, + error: row.error_message || null, + duration_ms: row.duration_ms || null, + cancel_requested: Number(row.cancel_requested || 0) === 1, + created_at: row.created_at, + started_at: row.started_at, + completed_at: row.completed_at, + }; +} + +function publicJob(row, { includeVariants = true } = {}) { + if (!row) return null; + const variants = includeVariants ? getVariantRows(row.id).map(publicVariant) : undefined; + const createdMs = parseDate(row.created_at); + const startedMs = parseDate(row.started_at); + const completedMs = parseDate(row.completed_at); + return { + id: row.id, + prompt: row.prompt, + final_prompt: row.final_prompt, + width: Number(row.width), + height: Number(row.height), + steps: Number(row.steps), + style: row.style, + profile: row.profile, + prompt_mode: row.prompt_mode, + assistant: Number(row.assistant || 0) === 1, + variants_count: Number(row.variants), + seed_base: Number(row.seed_base), + seed_lock: Number(row.seed_lock || 0) === 1, + status: row.status, + phase: row.phase, + message: row.message || '', + error: row.error_message || null, + completed_variants: Number(row.completed_variants || 0), + source_job_id: row.source_job_id || null, + created_at: row.created_at, + started_at: row.started_at, + completed_at: row.completed_at, + archived_at: row.archived_at || null, + archived: Boolean(row.archived_at), + elapsed_ms: completedMs && startedMs ? completedMs - startedMs : startedMs ? Date.now() - startedMs : createdMs ? Date.now() - createdMs : null, + variants, + }; +} + +function fetchJobRow(id) { + return db.prepare('SELECT * FROM flux_prompt_jobs WHERE id = ?').get(String(id || '')); +} + +function updateParentJob(jobId) { + const job = fetchJobRow(jobId); + if (!job) return null; + const variants = db.prepare('SELECT status, error_message FROM flux_prompt_job_variants WHERE job_id = ? ORDER BY variant_index').all(jobId); + const counts = variants.reduce((acc, item) => { + acc[item.status] = (acc[item.status] || 0) + 1; + return acc; + }, {}); + const completed = counts.completed || 0; + const terminal = variants.length > 0 && variants.every(item => TERMINAL_VARIANT_STATUSES.has(item.status)); + let status = job.status; + let phase = job.phase; + let message = job.message; + let errorMessage = null; + let completedAt = null; + if (terminal) { + completedAt = new Date().toISOString(); + if (completed === variants.length) { + status = 'completed'; + phase = 'completed'; + message = `${completed} Bildvariante${completed === 1 ? '' : 'n'} fertig.`; + } else if ((counts.cancelled || 0) === variants.length) { + status = 'cancelled'; + phase = 'cancelled'; + message = 'Auftrag wurde abgebrochen.'; + } else if (completed > 0) { + status = 'completed_with_errors'; + phase = 'completed_with_errors'; + message = `${completed} von ${variants.length} Varianten fertig.`; + errorMessage = variants.find(item => item.error_message)?.error_message || null; + } else { + status = 'failed'; + phase = 'failed'; + message = 'Alle Bildvarianten sind fehlgeschlagen.'; + errorMessage = variants.find(item => item.error_message)?.error_message || null; + } + } else if ((counts.running || 0) > 0) { + status = job.status === 'cancelling' ? 'cancelling' : 'running'; + phase = job.status === 'cancelling' ? 'cancelling' : 'rendering'; + message = job.status === 'cancelling' ? 'Abbruch wird an ComfyUI übergeben …' : `${counts.running} Variante${counts.running === 1 ? '' : 'n'} läuft, ${counts.queued || 0} wartet.`; + } else if ((counts.queued || 0) > 0) { + status = job.status === 'cancelling' ? 'cancelling' : 'queued'; + phase = job.status === 'cancelling' ? 'cancelling' : 'queued'; + message = job.status === 'cancelling' ? 'Auftrag wird abgebrochen …' : `${counts.queued} Variante${counts.queued === 1 ? '' : 'n'} wartet.`; + } + db.prepare(`UPDATE flux_prompt_jobs SET status = ?, phase = ?, message = ?, error_message = ?, completed_variants = ?, + completed_at = COALESCE(?, completed_at), updated_at = datetime('now') WHERE id = ?`) + .run(status, phase, message, errorMessage, completed, completedAt, jobId); + return getFluxPromptJob(jobId); +} + +function saveResultImage(job, variant, result, startedAt) { + const relative = `ai-generated/${randomUUID()}.png`; + writeFileSync(join(UPLOADS_DIR, relative), Buffer.from(result.base64, 'base64')); + const durationMs = Math.max(0, Date.now() - startedAt); + const image = createFluxImage({ + prompt: job.prompt, + final_prompt: job.final_prompt, + image_path: relative, + width: job.width, + height: job.height, + seed: variant.seed, + steps: job.steps, + style: job.style, + profile: job.profile, + duration_ms: durationMs, + job_id: job.id, + variant_index: variant.variant_index, + prompt_mode: job.prompt_mode, + parameters: { + width: Number(job.width), + height: Number(job.height), + steps: Number(job.steps), + style: job.style, + profile: job.profile, + prompt_mode: job.prompt_mode, + seed_lock: Number(job.seed_lock || 0) === 1, + variants: Number(job.variants), + }, + }); + db.prepare(`UPDATE flux_prompt_job_variants SET status = 'completed', phase = 'completed', message = ?, image_id = ?, + duration_ms = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run(`Variante ${variant.variant_index} ist fertig.`, image.id, durationMs, variant.id); + return image; +} + +async function variantCancelled(variantId) { + const row = db.prepare('SELECT cancel_requested FROM flux_prompt_job_variants WHERE id = ?').get(variantId); + return Number(row?.cancel_requested || 0) === 1; +} + +async function runVariant(variantId, { resume = false } = {}) { + if (activeVariants.has(variantId)) return; + const variant = db.prepare('SELECT * FROM flux_prompt_job_variants WHERE id = ?').get(variantId); + if (!variant) return; + const job = fetchJobRow(variant.job_id); + if (!job || !ACTIVE_JOB_STATUSES.has(job.status)) return; + activeVariants.set(variantId, true); + const startedAt = parseDate(variant.started_at) || Date.now(); + db.prepare(`UPDATE flux_prompt_job_variants SET status = 'running', phase = ?, message = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`) + .run(resume ? 'resuming' : 'preparing', resume ? 'Vorhandener ComfyUI-Auftrag wird wieder aufgenommen …' : 'FLUX-Workflow wird vorbereitet …', variantId); + db.prepare(`UPDATE flux_prompt_jobs SET status = CASE WHEN status = 'cancelling' THEN status ELSE 'running' END, + phase = CASE WHEN status = 'cancelling' THEN 'cancelling' ELSE 'rendering' END, + started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.id); + try { + const settings = getSettings(); + const onPhase = (phase, message) => { + db.prepare(`UPDATE flux_prompt_job_variants SET phase = ?, message = ?, updated_at = datetime('now') WHERE id = ?`) + .run(String(phase || 'running'), String(message || 'FLUX arbeitet …').slice(0, 500), variantId); + }; + let result; + if (resume && variant.prompt_id) { + result = await waitForLocalFluxPrompt({ + promptId: variant.prompt_id, + settings, + timeoutMs: 300000, + onPhase, + isCancelled: () => variantCancelled(variantId), + }); + } else { + result = await generateWithLocalFlux({ + prompt: job.final_prompt, + settings: { ...settings, ai_model_photo_use_source_reference: '0', ai_model_photo_local_resolution: String(Math.max(job.width, job.height) >= 1024 ? 1024 : 768) }, + mode: 'prompt', + variantIndex: Math.max(0, Number(variant.variant_index) - 1), + sourceDataUrl: '', + width: Number(job.width), + height: Number(job.height), + seed: Number(variant.seed), + steps: Number(job.steps), + onPhase, + onSubmitted: async (promptId) => { + db.prepare(`UPDATE flux_prompt_job_variants SET prompt_id = ?, phase = 'submitted', message = 'An ComfyUI übergeben …', updated_at = datetime('now') WHERE id = ?`) + .run(String(promptId), variantId); + }, + isCancelled: () => variantCancelled(variantId), + }); + } + if (await variantCancelled(variantId)) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + saveResultImage(job, variant, result, startedAt); + } catch (error) { + const cancelled = error?.code === 'FLUX_CANCELLED' || await variantCancelled(variantId); + db.prepare(`UPDATE flux_prompt_job_variants SET status = ?, phase = ?, message = ?, error_message = ?, + completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run( + cancelled ? 'cancelled' : 'failed', + cancelled ? 'cancelled' : 'failed', + cancelled ? 'Variante wurde abgebrochen.' : 'Bildgenerierung fehlgeschlagen.', + cancelled ? null : String(error?.message || error).slice(0, 2000), + variantId, + ); + } finally { + activeVariants.delete(variantId); + updateParentJob(job.id); + queueMicrotask(() => processQueue().catch(() => {})); + } +} + +function getMaxParallelJobs() { + const settings = getSettings(); + return Math.max(1, Math.min(4, Math.round(Number(settings.flux_max_parallel_jobs) || 1))); +} + +async function processQueue() { + if (loopBusy) return; + loopBusy = true; + try { + const maxParallel = getMaxParallelJobs(); + while (activeVariants.size < maxParallel) { + const next = db.prepare(`SELECT v.id FROM flux_prompt_job_variants v + JOIN flux_prompt_jobs j ON j.id = v.job_id + WHERE v.status = 'queued' AND v.cancel_requested = 0 AND j.archived_at IS NULL AND j.status IN ('queued','running') + ORDER BY j.created_at, v.variant_index LIMIT 1`).get(); + if (!next) break; + runVariant(next.id).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 10)); + } + } finally { + loopBusy = false; + } +} + +function createJob({ + prompt, + width = 768, + height = 768, + seed = null, + steps = 4, + style = 'none', + profile = 'balanced', + promptMode = 'precise', + assistant = true, + variants = 1, + seedLock = false, + sourceJobId = null, + explicitSeeds = null, +} = {}) { + const cleanPrompt = normalizeWhitespace(prompt); + if (cleanPrompt.length < 3) throw new Error('Bitte einen aussagekräftigen Prompt eingeben.'); + if (cleanPrompt.length > 2000) throw new Error('Der Prompt darf höchstens 2.000 Zeichen lang sein.'); + const normalizedProfile = cleanProfile(profile); + const dimensions = applyProfile({ profile: normalizedProfile, width, height, steps }); + const normalizedStyle = cleanStyle(style); + const normalizedMode = cleanMode(promptMode); + const variantCount = cleanVariantCount(variants); + const baseSeed = normalizeSeed(seed); + const seeds = buildSeeds(baseSeed, variantCount, seedLock === true, explicitSeeds); + const id = randomUUID(); + const finalPrompt = buildFluxPrompt(cleanPrompt, { style: normalizedStyle, mode: normalizedMode, assistant: assistant !== false }); + const insert = db.transaction(() => { + db.prepare(`INSERT INTO flux_prompt_jobs + (id, prompt, final_prompt, width, height, steps, style, profile, prompt_mode, assistant, variants, seed_base, seed_lock, + status, phase, message, source_job_id, settings_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 'queued', ?, ?, '{}')`) + .run(id, cleanPrompt, finalPrompt, dimensions.width, dimensions.height, dimensions.steps, normalizedStyle, normalizedProfile, + normalizedMode, assistant !== false ? 1 : 0, variantCount, seeds[0], seedLock === true ? 1 : 0, + `${variantCount} Variante${variantCount === 1 ? '' : 'n'} wartet${variantCount === 1 ? '' : 'en'} auf FLUX.`, sourceJobId || null); + const stmt = db.prepare(`INSERT INTO flux_prompt_job_variants + (id, job_id, variant_index, seed, status, phase, message) VALUES (?, ?, ?, ?, 'queued', 'queued', ?)`); + seeds.forEach((variantSeed, index) => stmt.run(randomUUID(), id, index + 1, variantSeed, `Variante ${index + 1} wartet.`)); + }); + insert(); + processQueue().catch(() => {}); + return getFluxPromptJob(id); +} + +export function enqueueFluxPromptJob(payload = {}) { + return createJob(payload); +} + +export function getFluxPromptJob(id) { + return publicJob(fetchJobRow(id)); +} + +function fluxStatusWhere(status = '') { + const normalized = String(status || '').trim(); + if (normalized === 'active') return { sql: "status IN ('queued','running','cancelling')", args: [] }; + if (normalized === 'completed') return { sql: "status IN ('completed','completed_with_errors')", args: [] }; + if (['failed', 'cancelled', 'queued', 'running', 'cancelling', 'completed', 'completed_with_errors'].includes(normalized)) { + return { sql: 'status = ?', args: [normalized] }; + } + return { sql: '', args: [] }; +} + +export function listFluxPromptJobs({ limit = 50, status = '', archived = false } = {}) { + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); + const statusClause = fluxStatusWhere(status); + const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL']; + if (statusClause.sql) where.push(statusClause.sql); + const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ?`) + .all(...statusClause.args, safeLimit); + return rows.map(row => publicJob(row)); +} + +export function paginateFluxPromptJobs({ page = 1, pageSize = 10, status = '', archived = false } = {}) { + const safePageSize = Math.max(5, Math.min(15, Number(pageSize) || 10)); + const requestedPage = Math.max(1, Number(page) || 1); + const statusClause = fluxStatusWhere(status); + const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL']; + if (statusClause.sql) where.push(statusClause.sql); + const whereSql = where.join(' AND '); + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE ${whereSql}`).get(...statusClause.args)?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${whereSql} ORDER BY created_at DESC LIMIT ? OFFSET ?`) + .all(...statusClause.args, safePageSize, (safePage - 1) * safePageSize); + return { + jobs: rows.map(row => publicJob(row)), + pagination: { page: safePage, page_size: safePageSize, total, total_pages: totalPages }, + }; +} + +function normalizeJobIds(ids = []) { + return [...new Set((Array.isArray(ids) ? ids : [ids]).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 500); +} + +export function archiveFluxPromptJobs(ids = []) { + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, archived: 0, skipped: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = datetime('now'), updated_at = datetime('now') + WHERE id IN (${placeholders}) AND archived_at IS NULL AND status NOT IN ('queued','running','cancelling')`).run(...jobIds); + return { ok: true, archived: Number(result.changes || 0), skipped: jobIds.length - Number(result.changes || 0) }; +} + +export function restoreFluxPromptJobs(ids = []) { + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, restored: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = NULL, updated_at = datetime('now') WHERE id IN (${placeholders}) AND archived_at IS NOT NULL`).run(...jobIds); + return { ok: true, restored: Number(result.changes || 0) }; +} + +export function deleteArchivedFluxPromptJobs(ids = [], { all = false } = {}) { + if (all) { + const result = db.prepare('DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').run(); + return { ok: true, deleted: Number(result.changes || 0) }; + } + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, deleted: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL AND id IN (${placeholders})`).run(...jobIds); + return { ok: true, deleted: Number(result.changes || 0) }; +} + +export function getFluxPromptJobSummary() { + const rows = db.prepare('SELECT status, COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NULL GROUP BY status').all(); + const jobs = Object.fromEntries(rows.map(row => [row.status, Number(row.count)])); + const archivedTotal = Number(db.prepare('SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').get()?.count || 0); + const variants = db.prepare(`SELECT + SUM(CASE WHEN v.status = 'running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN v.status = 'queued' THEN 1 ELSE 0 END) AS queued + FROM flux_prompt_job_variants v JOIN flux_prompt_jobs j ON j.id = v.job_id WHERE j.archived_at IS NULL`).get(); + return { + jobs, + archived_total: archivedTotal, + variants: { running: Number(variants?.running || 0), queued: Number(variants?.queued || 0) }, + active_workers: activeVariants.size, + max_parallel: getMaxParallelJobs(), + }; +} + +export async function cancelFluxPromptJob(id) { + const job = fetchJobRow(id); + if (!job) return null; + if (!ACTIVE_JOB_STATUSES.has(job.status)) return getFluxPromptJob(id); + const runningVariants = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE job_id = ? AND status = 'running'").all(id); + db.transaction(() => { + db.prepare("UPDATE flux_prompt_jobs SET status = 'cancelling', phase = 'cancelling', message = 'Abbruch wird ausgeführt …', updated_at = datetime('now') WHERE id = ?").run(id); + db.prepare("UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Vor Start abgebrochen.', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(id); + db.prepare("UPDATE flux_prompt_job_variants SET cancel_requested = 1, phase = 'cancelling', message = 'Abbruch angefordert …', updated_at = datetime('now') WHERE job_id = ? AND status = 'running'").run(id); + })(); + if (runningVariants.length > 0) { + const settings = getSettings(); + for (const variant of runningVariants) { + if (!variant.prompt_id) continue; + try { await cancelLocalFluxPrompt(variant.prompt_id, settings); } catch {} + } + } + updateParentJob(id); + return getFluxPromptJob(id); +} + +export function retryFluxPromptJob(id) { + const source = fetchJobRow(id); + if (!source) return null; + const variants = getVariantRows(id); + return createJob({ + prompt: source.prompt, + width: source.width, + height: source.height, + seed: source.seed_base, + steps: source.steps, + style: source.style, + profile: source.profile, + promptMode: source.prompt_mode, + assistant: Number(source.assistant || 0) === 1, + variants: source.variants, + seedLock: Number(source.seed_lock || 0) === 1, + sourceJobId: source.id, + explicitSeeds: variants.map(item => Number(item.seed)), + }); +} + +export function clearFluxPromptQueue() { + const jobIds = db.prepare(`SELECT DISTINCT job_id FROM flux_prompt_job_variants WHERE status = 'queued'`).all().map(row => row.job_id); + const result = db.prepare(`UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Aus Warteschlange entfernt.', + completed_at = datetime('now'), updated_at = datetime('now') WHERE status = 'queued'`).run(); + jobIds.forEach(updateParentJob); + return { ok: true, cleared_variants: Number(result.changes || 0), affected_jobs: jobIds.length }; +} + +export function startFluxPromptJobLoop() { + if (loopTimer) return; + const running = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE status = 'running'").all(); + for (const variant of running) { + if (variant.prompt_id) runVariant(variant.id, { resume: true }).catch(() => {}); + else db.prepare("UPDATE flux_prompt_job_variants SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart erneut eingereiht.', updated_at = datetime('now') WHERE id = ?").run(variant.id); + } + db.prepare("UPDATE flux_prompt_jobs SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart wieder aufgenommen.', updated_at = datetime('now') WHERE status = 'running' AND id NOT IN (SELECT job_id FROM flux_prompt_job_variants WHERE status = 'running')").run(); + loopTimer = setInterval(() => processQueue().catch(() => {}), 1000); + loopTimer.unref?.(); + processQueue().catch(() => {}); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local-images.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local-images.mjs new file mode 100644 index 0000000..f7bafeb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local-images.mjs @@ -0,0 +1,466 @@ +import { join, isAbsolute, dirname } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { randomBytes, randomUUID } from 'crypto'; + +const ROOT_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = join(ROOT_DIR, '..'); +const DEFAULT_WORKFLOW_RELATIVE = 'local-ai/workflows/vendoo_flux_schnell_api.json'; + +function toPosix(path) { + return String(path || '').replace(/\\/g, '/'); +} + +function resolveWorkflowPath(pathValue = '') { + const trimmed = String(pathValue || '').trim(); + if (!trimmed) return join(PROJECT_DIR, DEFAULT_WORKFLOW_RELATIVE); + return isAbsolute(trimmed) ? trimmed : join(PROJECT_DIR, trimmed); +} + +function getEnvBool(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase()); +} + +export function generateLocalImageToken() { + return randomBytes(24).toString('base64url'); +} + + + +export function getLocalImageConfig(settings = {}) { + const workflowRelative = String(settings.ai_model_photo_local_workflow_path || process.env.LOCAL_IMAGE_WORKFLOW_PATH || DEFAULT_WORKFLOW_RELATIVE).trim() || DEFAULT_WORKFLOW_RELATIVE; + const workflowPath = resolveWorkflowPath(workflowRelative); + return { + enabled: getEnvBool(settings.ai_model_photo_local_enabled ?? process.env.LOCAL_IMAGE_ENABLED, true), + url: String(settings.ai_model_photo_local_url || process.env.LOCAL_IMAGE_URL || 'http://127.0.0.1:8188').trim().replace(/\/$/, ''), + token: String(settings.ai_model_photo_local_token || process.env.LOCAL_IMAGE_TOKEN || '').trim(), + workflow_path: workflowPath, + workflow_relative: toPosix(workflowRelative), + install_path: String(settings.ai_model_photo_local_install_path || process.env.LOCAL_IMAGE_INSTALL_PATH || '').trim(), + require_token: getEnvBool(settings.ai_model_photo_local_require_token ?? process.env.LOCAL_IMAGE_REQUIRE_TOKEN, true), + allow_remote: getEnvBool(process.env.LOCAL_IMAGE_ALLOW_REMOTE, false), + }; +} + +function assertSafeLocalUrl(config) { + let parsed; + try { parsed = new URL(config.url); } catch { throw new Error('Ungültige lokale Bildserver-URL.'); } + const host = String(parsed.hostname || '').toLowerCase(); + const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host); + if (!loopback && !config.allow_remote) { + throw new Error('Aus Sicherheitsgründen sind für lokalen FLUX standardmäßig nur localhost/127.0.0.1 erlaubt.'); + } + return { loopback, host }; +} + +function getAuthHeaders(config) { + const headers = {}; + if (config.token) { + headers['Authorization'] = `Bearer ${config.token}`; + headers['X-API-Key'] = config.token; + } + return headers; +} + +function getHeaders(config) { + return { ...getAuthHeaders(config), 'Content-Type': 'application/json' }; +} + + +async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +function replaceDeep(value, replacements) { + if (typeof value === 'string') { + let out = value; + for (const [key, val] of Object.entries(replacements)) { + out = out.split(key).join(String(val)); + } + return out; + } + if (Array.isArray(value)) return value.map(item => replaceDeep(item, replacements)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceDeep(v, replacements)])); + } + return value; +} + +function firstImageFromHistory(history = {}) { + for (const node of Object.values(history?.outputs || {})) { + if (Array.isArray(node?.images) && node.images.length) return node.images[0]; + } + return null; +} + +async function pollHistory(config, promptId, timeoutMs = 300000, onProgress = null, { onPhase = null, isCancelled = null } = {}) { + const started = Date.now(); + let lastPayload = null; + while (Date.now() - started < timeoutMs) { + if (typeof isCancelled === 'function' && await isCancelled()) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + const elapsedSeconds = Math.max(0, Math.round((Date.now() - started) / 1000)); + const message = elapsedSeconds < 8 + ? 'ComfyUI übernimmt den Auftrag …' + : `FLUX rendert das Bild seit ${elapsedSeconds} Sekunden …`; + if (typeof onPhase === 'function') onPhase('rendering', message, { elapsed_seconds: elapsedSeconds }); + if (typeof onProgress === 'function') { + const estimated = Math.min(88, 24 + Math.round((elapsedSeconds / 150) * 60)); + onProgress(estimated, message); + } + const response = await fetchWithTimeout(`${config.url}/history/${promptId}`, { headers: getHeaders(config) }, 12000); + const payload = await response.json().catch(() => ({})); + lastPayload = payload?.[promptId] || payload; + const image = firstImageFromHistory(lastPayload); + if (image) return { history: lastPayload, image }; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error('Lokale FLUX/ComfyUI-Generierung hat nach fünf Minuten das Zeitlimit überschritten.'); +} + +async function fetchComfyImageAsBase64(config, imageInfo) { + const params = new URLSearchParams({ filename: imageInfo.filename, subfolder: imageInfo.subfolder || '', type: imageInfo.type || 'output' }); + const response = await fetchWithTimeout(`${config.url}/view?${params.toString()}`, { headers: getHeaders(config) }, 60000); + if (!response.ok) throw new Error(`ComfyUI-Bildabruf fehlgeschlagen (${response.status})`); + return Buffer.from(await response.arrayBuffer()).toString('base64'); +} + +function parseDataUrl(dataUrl = '') { + const match = String(dataUrl || '').match(/^data:([^;,]+);base64,(.+)$/s); + if (!match) return null; + return { mime: match[1] || 'image/jpeg', buffer: Buffer.from(match[2], 'base64') }; +} + +async function uploadSourceImage(config, sourceDataUrl, onProgress = null) { + const parsed = parseDataUrl(sourceDataUrl); + if (!parsed?.buffer?.length) return null; + if (typeof onProgress === 'function') onProgress(8, 'Produktfotoreferenz wird an ComfyUI übertragen …'); + const extension = parsed.mime.includes('png') ? 'png' : parsed.mime.includes('webp') ? 'webp' : 'jpg'; + const filename = `vendoo-reference-${randomUUID()}.${extension}`; + const form = new FormData(); + form.append('image', new Blob([parsed.buffer], { type: parsed.mime }), filename); + form.append('type', 'input'); + form.append('overwrite', 'true'); + const response = await fetchWithTimeout(`${config.url}/upload/image`, { + method: 'POST', + headers: getAuthHeaders(config), + body: form, + }, 60000); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.name) { + const detail = payload?.error || payload?.message || `HTTP ${response.status}`; + throw new Error(`Produktfotoreferenz konnte nicht an ComfyUI übertragen werden: ${detail}`); + } + const imageName = payload.subfolder ? `${payload.subfolder}/${payload.name}` : payload.name; + return { imageName, payload }; +} + +function applySourceImageConditioning(workflow, imageName, mode = 'model', referenceStrength = 70, resolution = 768) { + if (!imageName || !workflow?.['7']?.inputs || !workflow?.['3']) return { workflow, used: false, denoise: 1 }; + const strength = Math.max(30, Math.min(90, Number(referenceStrength || 70))); + const targetSize = Number(resolution) === 1024 ? 1024 : 768; + const baseDenoise = mode === 'flatlay' ? 0.52 : mode === 'ghost_mannequin' ? 0.64 : 0.80; + const denoise = Math.max(0.42, Math.min(0.88, Number((baseDenoise - ((strength - 60) * 0.0035)).toFixed(3)))); + workflow['10'] = { inputs: { image: imageName, upload: 'image' }, class_type: 'LoadImage' }; + workflow['11'] = { + inputs: { upscale_method: 'lanczos', width: targetSize, height: targetSize, crop: 'disabled', image: ['10', 0] }, + class_type: 'ImageScale', + }; + workflow['12'] = { inputs: { pixels: ['11', 0], vae: ['3', 2] }, class_type: 'VAEEncode' }; + workflow['7'].inputs.latent_image = ['12', 0]; + workflow['7'].inputs.denoise = denoise; + return { workflow, used: true, denoise }; +} + +export async function getLocalImageStatus(settings = {}) { + const config = getLocalImageConfig(settings); + const workflowExists = existsSync(config.workflow_path); + const configuredInstallRoot = config.install_path + ? (isAbsolute(config.install_path) ? config.install_path : join(PROJECT_DIR, config.install_path)) + : join(PROJECT_DIR, 'local-ai', 'comfyui'); + const portableRoot = existsSync(join(configuredInstallRoot, 'ComfyUI', 'main.py')) + ? configuredInstallRoot + : join(configuredInstallRoot, 'ComfyUI_windows_portable'); + const comfyMain = join(portableRoot, 'ComfyUI', 'main.py'); + const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'); + const status = { + enabled: config.enabled, + url: config.url, + token_set: !!config.token, + require_token: config.require_token, + workflow_path: config.workflow_relative, + workflow_exists: workflowExists, + install_path: config.install_path, + comfy_installed: existsSync(comfyMain), + model_installed: existsSync(modelPath), + model_path: modelPath, + reachable: false, + installed: existsSync(comfyMain), + localhost_only: !config.allow_remote, + reference_image_supported: true, + message: '', + }; + try { + const security = assertSafeLocalUrl(config); + status.loopback = security.loopback; + const res = await fetchWithTimeout(`${config.url}/system_stats`, { headers: getHeaders(config) }, 8000); + status.reachable = res.ok; + status.installed = res.ok; + const payload = await res.json().catch(() => ({})); + status.system = payload?.system || payload || null; + status.message = res.ok + ? `Lokaler Bildserver erreichbar${workflowExists ? '' : ' · Workflow-Datei fehlt noch'}.` + : `Lokaler Bildserver antwortet mit HTTP ${res.status}.`; + } catch (error) { + status.message = `Lokaler Bildserver nicht erreichbar: ${error.message || error}`; + } + return status; +} + +export async function generateWithLocalFlux({ + prompt, + settings = {}, + mode = 'model', + variantIndex = 0, + sourceDataUrl = '', + onProgress = null, + onPhase = null, + onSubmitted = null, + isCancelled = null, + width = null, + height = null, + seed = null, + steps = null, +}) { + const config = getLocalImageConfig(settings); + const throwIfCancelled = async () => { + if (typeof isCancelled === 'function' && await isCancelled()) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + }; + await throwIfCancelled(); + if (!config.enabled) throw new Error('Lokale FLUX-Bildgenerierung ist deaktiviert.'); + assertSafeLocalUrl(config); + if (!existsSync(config.workflow_path)) throw new Error(`Workflow-Datei nicht gefunden: ${config.workflow_relative}`); + if (typeof onPhase === 'function') onPhase('preparing', 'Lokaler FLUX-Workflow wird vorbereitet …'); + if (typeof onProgress === 'function') onProgress(3, 'Lokaler FLUX-Workflow wird vorbereitet …'); + const workflowText = readFileSync(config.workflow_path, 'utf8').replace(/^/, ''); + let raw; + try { + raw = JSON.parse(workflowText); + } catch (error) { + throw new Error(`Lokaler FLUX-Workflow ist kein gültiges JSON: ${error.message || error}`); + } + const replacements = { + '__PROMPT__': prompt, + '__NEGATIVE_PROMPT__': mode === 'prompt' ? '' : 'nsfw, nudity, lingerie, child, low quality, extra limbs, cropped garment, wrong garment, different clothing, changed logo, changed color', + '__SEED__': Number.isFinite(Number(seed)) ? Math.max(0, Math.min(2147483647, Number(seed))) : Math.floor(Math.random() * 2147483647), + '__MODE__': mode, + '__VARIANT_INDEX__': variantIndex + 1, + '__SOURCE_IMAGE__': sourceDataUrl || '', + '__CLIENT_ID__': 'vendoo', + }; + let workflow = replaceDeep(raw, replacements); + if (workflow?.['7']?.inputs) workflow['7'].inputs.seed = replacements.__SEED__; + const resolution = Number(settings.ai_model_photo_local_resolution) === 1024 ? 1024 : 768; + const targetWidth = Number(width) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(width) / 8) * 8)) : resolution; + const targetHeight = Number(height) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(height) / 8) * 8)) : resolution; + if (workflow?.['6']?.inputs) { + workflow['6'].inputs.width = targetWidth; + workflow['6'].inputs.height = targetHeight; + } + const targetSteps = Number.isFinite(Number(steps)) ? Math.max(4, Math.min(20, Math.round(Number(steps)))) : null; + if (targetSteps && workflow?.['7']?.inputs) workflow['7'].inputs.steps = targetSteps; + if (mode === 'prompt' && workflow?.['7']?.inputs) { + workflow['7'].inputs.cfg = 1; + workflow['7'].inputs.sampler_name = 'euler'; + workflow['7'].inputs.scheduler = 'simple'; + workflow['7'].inputs.denoise = 1; + } + const useReference = getEnvBool(settings.ai_model_photo_use_source_reference, true) && !!sourceDataUrl; + let reference = { used: false, denoise: 1 }; + if (useReference) { + const uploaded = await uploadSourceImage(config, sourceDataUrl, onProgress); + if (uploaded?.imageName) reference = applySourceImageConditioning(workflow, uploaded.imageName, mode, settings.ai_model_photo_reference_strength || 70, resolution); + } + if (typeof onPhase === 'function') onPhase('submitting', reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.'); + if (typeof onProgress === 'function') onProgress(16, reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.'); + await throwIfCancelled(); + const submit = await fetchWithTimeout(`${config.url}/prompt`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({ client_id: `vendoo-${randomUUID()}`, prompt: workflow }), + }, 30000); + const submitPayload = await submit.json().catch(() => ({})); + if (!submit.ok || !submitPayload.prompt_id) { + const message = submitPayload?.error?.message || submitPayload?.error || submitPayload?.message || `HTTP ${submit.status}`; + throw new Error(`ComfyUI-Prompt fehlgeschlagen: ${message}`); + } + if (typeof onSubmitted === 'function') await onSubmitted(submitPayload.prompt_id); + try { + await throwIfCancelled(); + } catch (error) { + try { await cancelLocalFluxPrompt(submitPayload.prompt_id, settings); } catch {} + throw error; + } + if (typeof onPhase === 'function') onPhase('submitted', 'Auftrag wurde an FLUX übergeben …', { prompt_id: submitPayload.prompt_id }); + if (typeof onProgress === 'function') onProgress(22, 'Auftrag wurde an FLUX übergeben …'); + const done = await pollHistory(config, submitPayload.prompt_id, 300000, onProgress, { onPhase, isCancelled }); + if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …'); + if (typeof onProgress === 'function') onProgress(92, 'Fertiges Bild wird von ComfyUI geladen …'); + const base64 = await fetchComfyImageAsBase64(config, done.image); + if (typeof onPhase === 'function') onPhase('completed', 'Lokales FLUX-Bild wurde erzeugt.'); + if (typeof onProgress === 'function') onProgress(100, 'Lokales FLUX-Bild wurde erzeugt.'); + return { + base64, + model: 'flux-schnell-local', + provider: 'local', + label: 'Lokales FLUX / ComfyUI', + free: true, + prompt_id: submitPayload.prompt_id, + reference_used: reference.used, + reference_denoise: reference.denoise, + resolution, + width: targetWidth, + height: targetHeight, + seed: replacements.__SEED__, + steps: targetSteps || workflow?.['7']?.inputs?.steps || 6, + }; +} + + +export async function waitForLocalFluxPrompt({ promptId, settings = {}, timeoutMs = 300000, onPhase = null, isCancelled = null } = {}) { + const config = getLocalImageConfig(settings); + if (!promptId) throw new Error('ComfyUI-Prompt-ID fehlt.'); + assertSafeLocalUrl(config); + const done = await pollHistory(config, String(promptId), timeoutMs, null, { onPhase, isCancelled }); + if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …'); + const base64 = await fetchComfyImageAsBase64(config, done.image); + return { base64, prompt_id: String(promptId), history: done.history, image: done.image }; +} + +export async function interruptLocalFlux(settings = {}) { + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + const response = await fetchWithTimeout(`${config.url}/interrupt`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({}), + }, 15000); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Abbruch fehlgeschlagen (${response.status})`); + return { ok: true, payload }; +} + +function comfyQueuePromptIds(items = []) { + return (Array.isArray(items) ? items : []) + .map(item => Array.isArray(item) ? item[1] : item?.prompt_id || item?.id) + .filter(Boolean) + .map(String); +} + +export async function cancelLocalFluxPrompt(promptId, settings = {}) { + const id = String(promptId || '').trim(); + if (!id) return { ok: false, mode: 'not_submitted' }; + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + + const queueResponse = await fetchWithTimeout(`${config.url}/queue`, { headers: getHeaders(config) }, 12000); + const queue = await queueResponse.json().catch(() => ({})); + if (!queueResponse.ok) throw new Error(queue?.error || queue?.message || `ComfyUI-Queue konnte nicht gelesen werden (${queueResponse.status})`); + + const runningIds = comfyQueuePromptIds(queue?.queue_running); + const pendingIds = comfyQueuePromptIds(queue?.queue_pending); + if (pendingIds.includes(id)) { + const response = await fetchWithTimeout(`${config.url}/queue`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({ delete: [id] }), + }, 15000); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Warteschlangenauftrag konnte nicht entfernt werden (${response.status})`); + return { ok: true, mode: 'dequeued', prompt_id: id, payload }; + } + if (runningIds.includes(id)) { + const interrupted = await interruptLocalFlux(settings); + return { ...interrupted, mode: 'interrupted', prompt_id: id }; + } + return { ok: true, mode: 'already_finished_or_missing', prompt_id: id }; +} + +export async function getLocalFluxRuntimeInfo(settings = {}, { historyLimit = 20 } = {}) { + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + const fetchJson = async (path, timeout = 12000) => { + const response = await fetchWithTimeout(`${config.url}${path}`, { headers: getHeaders(config) }, timeout); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`${path} antwortet mit HTTP ${response.status}`); + return payload; + }; + const [systemResult, queueResult, historyResult] = await Promise.allSettled([ + fetchJson('/system_stats'), + fetchJson('/queue'), + fetchJson(`/history?max_items=${Math.max(1, Math.min(100, Number(historyLimit) || 20))}`), + ]); + const systemStats = systemResult.status === 'fulfilled' ? systemResult.value : null; + const queue = queueResult.status === 'fulfilled' ? queueResult.value : null; + const history = historyResult.status === 'fulfilled' ? historyResult.value : null; + const running = Array.isArray(queue?.queue_running) ? queue.queue_running.length : 0; + const pending = Array.isArray(queue?.queue_pending) ? queue.queue_pending.length : 0; + const historyItems = history && typeof history === 'object' ? Object.entries(history).slice(0, Math.max(1, Number(historyLimit) || 20)) : []; + return { + reachable: !!systemStats || !!queue || !!history, + url: config.url, + system_stats: systemStats, + queue, + history: Object.fromEntries(historyItems), + queue_summary: { running, pending, total: running + pending }, + errors: { + system_stats: systemResult.status === 'rejected' ? systemResult.reason?.message || String(systemResult.reason) : null, + queue: queueResult.status === 'rejected' ? queueResult.reason?.message || String(queueResult.reason) : null, + history: historyResult.status === 'rejected' ? historyResult.reason?.message || String(historyResult.reason) : null, + }, + }; +} + + +export function ensureLocalFluxBootstrap({ token = '', installPath = '' } = {}) { + const localDir = join(PROJECT_DIR, 'local-ai'); + const workflowsDir = join(localDir, 'workflows'); + mkdirSync(workflowsDir, { recursive: true }); + const workflowPath = join(workflowsDir, 'vendoo_flux_schnell_api.json'); + if (!existsSync(workflowPath)) { + const workflow = { + '3': { inputs: { ckpt_name: 'flux1-schnell-fp8.safetensors' }, class_type: 'CheckpointLoaderSimple' }, + '4': { inputs: { text: '__PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' }, + '5': { inputs: { text: '__NEGATIVE_PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' }, + '6': { inputs: { width: 1024, height: 1024, batch_size: 1 }, class_type: 'EmptyLatentImage' }, + '7': { inputs: { seed: '__SEED__', steps: 6, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1, model: ['3', 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] }, class_type: 'KSampler' }, + '8': { inputs: { samples: ['7', 0], vae: ['3', 2] }, class_type: 'VAEDecode' }, + '9': { inputs: { filename_prefix: 'Vendoo/flux_prompt', images: ['8', 0] }, class_type: 'SaveImage' }, + }; + writeFileSync(workflowPath, JSON.stringify(workflow, null, 2), 'utf8'); + } + const envLocalPath = join(localDir, '.env.local-flux'); + if (!existsSync(envLocalPath)) { + writeFileSync(envLocalPath, [ + 'LOCAL_IMAGE_URL=http://127.0.0.1:8188', + `LOCAL_IMAGE_TOKEN=${token || generateLocalImageToken()}`, + `LOCAL_IMAGE_WORKFLOW_PATH=${DEFAULT_WORKFLOW_RELATIVE}`, + `LOCAL_IMAGE_INSTALL_PATH=${installPath}`, + ].join('\n') + '\n', 'utf8'); + } + return { workflow_path: toPosix(DEFAULT_WORKFLOW_RELATIVE), env_file: toPosix('local-ai/.env.local-flux') }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local.mjs new file mode 100644 index 0000000..f654a54 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-local.mjs @@ -0,0 +1,64 @@ +export const MODELS = [ + { id: 'llama3.2-vision', name: 'Llama 3.2 Vision (11B)' }, + { id: 'llava', name: 'LLaVA 1.6 (7B)' }, + { id: 'moondream', name: 'Moondream 2 (1.8B, schnell)' }, + { id: 'gemma3', name: 'Gemma 3 (Vision)' }, +]; + +export async function generate(images, systemPrompt, model) { + const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + const modelId = model || MODELS[0].id; + + const ollamaImages = images.map(img => img.base64); + + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + images: ollamaImages, + }, + ], + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama Fehler (${res.status}): ${text.slice(0, 200)}`); + } + + const data = await res.json(); + const text = data.message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt, model) { + const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + const modelId = model || MODELS[0].id; + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama Fehler (${res.status}): ${text.slice(0, 200)}`); + } + const data = await res.json(); + return String(data.message?.content || '').trim(); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-jobs.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-jobs.mjs new file mode 100644 index 0000000..5864cfa --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-jobs.mjs @@ -0,0 +1,127 @@ +import { + createAiModelGeneration, + updateAiModelGeneration, + createAiModelPhoto, + createAiModelJob, + updateAiModelJob, + getAiModelJob, + getPendingAiModelJobs, +} from './db.mjs'; +import { generateAiModelPhotos } from './ai-model-photos.mjs'; + +let processing = false; +let loopStarted = false; + +async function processOneJob(job) { + let lastProgress = -1; + let lastMessage = ''; + const reportProgress = (progress, message = '') => { + const normalized = Math.max(1, Math.min(99, Math.round(Number(progress || 0)))); + const cleanMessage = String(message || '').trim(); + if (normalized === lastProgress && cleanMessage === lastMessage) return; + lastProgress = normalized; + lastMessage = cleanMessage; + updateAiModelJob(job.id, { + status: 'running', + progress: normalized, + progress_message: cleanMessage || 'AI-Bild wird erzeugt …', + error_message: null, + }); + }; + + reportProgress(4, 'Job wird vorbereitet …'); + try { + const payload = job.payload_json || {}; + const result = await generateAiModelPhotos({ ...payload, settings: payload.settings || {} }, reportProgress); + reportProgress(94, 'Ergebnis wird gespeichert …'); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || result.mode || 'flatlay', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || payload.mode || 'flatlay', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + mode: payload.mode || result.mode || 'flatlay', + }); + updateAiModelJob(job.id, { + status: 'completed', + progress: 100, + progress_message: 'AI-Bilder sind fertig.', + generation_id: generation.id, + }); + return { generation, photos, provider: result.provider || null }; + } catch (err) { + updateAiModelJob(job.id, { + status: 'failed', + progress: 100, + progress_message: 'AI-Bildgenerierung fehlgeschlagen.', + error_message: err.message || 'Unbekannter Fehler', + }); + throw err; + } +} +async function processQueue() { + if (processing) return; + processing = true; + try { + const pending = getPendingAiModelJobs(5); + for (const job of pending) { + try { + await processOneJob(job); + } catch {} + } + } finally { + processing = false; + } +} + +export function startAiModelJobLoop() { + if (loopStarted) return; + loopStarted = true; + setInterval(() => { processQueue().catch(() => {}); }, 2500).unref?.(); +} + +export function enqueueAiModelJob(payload = {}) { + const job = createAiModelJob({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || 'model', + preset: payload.preset || 'mixed', + variants: payload.variants || 1, + status: 'queued', + progress: 0, + payload_json: payload, + }); + processQueue().catch(() => {}); + return job; +} + +export function getAiModelJobWithRelations(id) { + return getAiModelJob(id); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-photos.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-photos.mjs new file mode 100644 index 0000000..78ff0c1 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-model-photos.mjs @@ -0,0 +1,664 @@ +import OpenAI from 'openai'; +import { join, extname } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; +import { generateWithLocalFlux, getLocalImageConfig } from './ai-local-images.mjs'; + +const uploadsDir = UPLOADS_DIR; +const modelDir = join(uploadsDir, 'ai-models'); +mkdirSync(modelDir, { recursive: true }); + +const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models?output_modalities=image&sort=newest'; +const OPENROUTER_IMAGES_URL = 'https://openrouter.ai/api/v1/images/generations'; + +let openaiClient; +function getOpenAIClient() { + if (!process.env.OPENAI_API_KEY) throw new Error('OPENAI_API_KEY fehlt.'); + if (!openaiClient) openaiClient = new OpenAI(); + return openaiClient; +} + +const BLOCKED_KEYWORDS = [ + 'unterwäsche', 'lingerie', 'dessous', 'bikini', 'bademode', 'badeanzug', 'bh', 'slip', 'string', 'tanga', + 'babydoll', 'korsage', 'corsage', 'fetisch', 'latex', 'erotik', 'transparent', 'see-through', 'mesh', 'sexy' +]; +const KIDS_KEYWORDS = ['baby', 'kinder', 'kind', 'jungen', 'mädchen', 'maedchen', 'kleinkind', 'kids']; +const CLOTHING_KEYWORDS = ['shirt', 't-shirt', 'top', 'bluse', 'hemd', 'hoodie', 'pullover', 'sweatshirt', 'jacke', 'mantel', 'kleid', 'rock', 'hose', 'jeans', 'cardigan', 'sakko', 'blazer', 'overall', 'jumpsuit', 'schuhe', 'sneaker', 'tasche', 'bekleidung', 'fashion', 'mode']; +const PRESET_VARIANTS = { + studio: ['helles Studio mit weichem Schattenwurf', 'neutraler Editorial-Look vor hellem Hintergrund', 'cleaner Studio-Catalog-Look'], + indoor: ['natürliche Indoor-Szene in modernem Wohnraum', 'ruhige Boutique-Atmosphäre mit natürlichem Licht', 'casual Lifestyle-Interior mit sauberem Hintergrund'], + outdoor: ['ruhige urbane Außenumgebung bei natürlichem Tageslicht', 'dezente Outdoor-Szene mit unscharfem Hintergrund', 'cleaner Streetstyle-Look mit weichem Licht'], + mixed: ['helles Studio mit weichem Schattenwurf', 'natürliche Indoor-Szene in modernem Wohnraum', 'ruhige urbane Außenumgebung bei natürlichem Tageslicht'], +}; + +function safeJsonParse(text, fallback = null) { + try { return JSON.parse(text); } catch {} + const match = String(text || '').match(/\{[\s\S]*\}/); + if (!match) return fallback; + try { return JSON.parse(match[0]); } catch { return fallback; } +} + +function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } +function numeric(val) { const num = Number(val ?? 0); return Number.isFinite(num) ? num : 0; } + +function normalizeListingContext(input = {}) { + return { + title: String(input.title || '').trim(), + category: String(input.category || '').trim(), + brand: String(input.brand || '').trim(), + color: String(input.color || '').trim(), + size: String(input.size || '').trim(), + condition: String(input.condition || '').trim(), + seller_notes: String(input.seller_notes || '').trim(), + platform: String(input.platform || '').trim() || 'vinted', + }; +} + +function keywordFlags(context) { + const hay = `${context.title} ${context.category} ${context.seller_notes}`.toLowerCase(); + return { + blocked: BLOCKED_KEYWORDS.filter(word => hay.includes(word)), + kids: KIDS_KEYWORDS.filter(word => hay.includes(word)), + clothing: CLOTHING_KEYWORDS.some(word => hay.includes(word)), + }; +} + +function heuristicAnalysis(context) { + const flags = keywordFlags(context); + const garmentType = CLOTHING_KEYWORDS.find(word => (`${context.title} ${context.category}`).toLowerCase().includes(word)) || 'Kleidungsstück'; + return { + category: context.category || 'Mode', + garmentType, + color: context.color || null, + pattern: null, + material: null, + fit: null, + style: null, + age_group: flags.kids.length ? 'kids' : 'adult', + suitable_for_model_photos: flags.blocked.length === 0, + has_person: false, + confidence: 'low', + notes: flags.clothing ? 'Heuristik erkennt Modeartikel.' : 'Heuristik ohne visuelle Analyse.', + }; +} + +function getUploadPath(filename) { return join(uploadsDir, String(filename || '')); } + +function fileToDataUrl(filename) { + const full = getUploadPath(filename); + const ext = extname(full).toLowerCase(); + const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; + const b64 = readFileSync(full).toString('base64'); + return `data:${mime};base64,${b64}`; +} + +function parseOpenRouterModel(model = {}) { + const inputModalities = model.architecture?.input_modalities || []; + const outputModalities = model.architecture?.output_modalities || []; + return { + id: model.id, + name: model.name || model.id, + description: model.description || '', + created: model.created || null, + input_modalities: inputModalities, + output_modalities: outputModalities, + pricing: model.pricing || {}, + supported_parameters: model.supported_parameters || [], + is_free: isOpenRouterFreeModel(model), + supports_image_input: inputModalities.includes('image'), + supports_image_output: outputModalities.includes('image'), + }; +} + +function priceIsZero(value) { + if (value === undefined || value === null || value === '') return true; + const number = Number(value); + return Number.isFinite(number) && number === 0; +} + +function isOpenRouterFreeModel(model = {}) { + const id = String(model.id || '').toLowerCase(); + const name = String(model.name || '').toLowerCase(); + if (id.endsWith(':free') || name.includes('(free)')) return true; + + const pricing = model.pricing || {}; + // Nur Preise bewerten, die für eine normale Bildanforderung relevant sind. + // Optionale Web-/Audio-Preise dürfen ein ansonsten kostenloses Bildmodell nicht ausschließen. + const relevantKeys = ['request', 'prompt', 'completion', 'image']; + const present = relevantKeys.filter(key => pricing[key] !== undefined && pricing[key] !== null && pricing[key] !== ''); + return present.length > 0 && present.every(key => priceIsZero(pricing[key])); +} + +function getOpenRouterHeaders() { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY fehlt.'); + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }; + if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL; + if (process.env.OPENROUTER_APP_NAME) headers['X-Title'] = process.env.OPENROUTER_APP_NAME; + return headers; +} + +export async function fetchOpenRouterImageModels({ freeOnly = true } = {}) { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { ...getOpenRouterHeaders(), 'Cache-Control': 'no-cache' }, + cache: 'no-store', + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`OpenRouter Models API Fehler (${response.status})${body ? `: ${body.slice(0, 180)}` : ''}`); + } + const payload = await response.json(); + const list = Array.isArray(payload?.data) ? payload.data.map(parseOpenRouterModel) : []; + const imageModels = list.filter(model => model.supports_image_output); + const filtered = freeOnly ? imageModels.filter(model => model.is_free) : imageModels; + return filtered.sort((a, b) => Number(b.created || 0) - Number(a.created || 0)); +} + +export async function getOpenRouterImageDiagnostics() { + const allModels = await fetchOpenRouterImageModels({ freeOnly: false }); + const freeModels = allModels.filter(model => model.is_free); + return { + image_model_count: allModels.length, + free_image_model_count: freeModels.length, + free_models: freeModels, + all_models: allModels, + message: freeModels.length + ? `${freeModels.length} kostenlose OpenRouter-Bildmodelle verfügbar.` + : 'Aktuell wurde kein kostenloses OpenRouter-Modell mit Bildausgabe gefunden.', + }; +} + +async function resolveImageProviders(settings = {}) { + const preference = String(settings.ai_model_photo_provider || 'auto').toLowerCase(); + const openrouterFreeOnly = String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const configuredOpenRouterModel = String(settings.ai_model_photo_openrouter_model || '').trim(); + const configuredOpenAiModel = String(settings.ai_model_photo_openai_model || 'gpt-image-1').trim() || 'gpt-image-1'; + const localConfig = getLocalImageConfig(settings); + const candidates = []; + const discoveryErrors = []; + + const allowLocal = preference === 'local' || preference === 'auto'; + const allowOpenRouter = preference === 'openrouter' || preference === 'auto'; + const allowOpenAi = preference === 'openai' || preference === 'auto'; + + if (allowLocal && localConfig.enabled && localConfig.url) { + candidates.push({ + provider: 'local', + model: 'flux-schnell-local', + label: 'Lokales FLUX / ComfyUI', + free: true, + local: true, + discovered_models: [], + }); + } else if (preference === 'local') { + discoveryErrors.push('Lokaler FLUX/ComfyUI-Provider ist nicht aktiviert.'); + } + + if (allowOpenRouter && process.env.OPENROUTER_API_KEY) { + try { + const models = await fetchOpenRouterImageModels({ freeOnly: openrouterFreeOnly }); + const ordered = []; + const configured = models.find(model => model.id === configuredOpenRouterModel); + if (configured) ordered.push(configured); + for (const model of models) { + if (!ordered.some(entry => entry.id === model.id)) ordered.push(model); + } + for (const model of ordered) { + candidates.push({ + provider: 'openrouter', + model: model.id, + label: model.name, + free: model.is_free, + supports_image_input: model.supports_image_input, + supported_parameters: model.supported_parameters || [], + discovered_models: models, + }); + } + if (!ordered.length) discoveryErrors.push(openrouterFreeOnly ? 'Aktuell sind keine kostenlosen OpenRouter-Bildmodelle mit Bildausgabe verfügbar.' : 'Keine passenden OpenRouter-Bildmodelle gefunden.'); + } catch (error) { + discoveryErrors.push(error.message || 'OpenRouter-Modellabfrage fehlgeschlagen.'); + } + } else if (preference === 'openrouter' && !process.env.OPENROUTER_API_KEY) { + discoveryErrors.push('OPENROUTER_API_KEY fehlt.'); + } + + if (allowOpenAi && process.env.OPENAI_API_KEY) { + candidates.push({ + provider: 'openai', + model: configuredOpenAiModel, + label: configuredOpenAiModel, + free: false, + discovered_models: [], + }); + } else if (preference === 'openai' && !process.env.OPENAI_API_KEY) { + discoveryErrors.push('OPENAI_API_KEY fehlt.'); + } + + if (!candidates.length) { + throw new Error(discoveryErrors.filter(Boolean).join(' | ') || 'Kein Bildprovider konfiguriert.'); + } + return candidates; +} + +export async function analyzeGarmentPhotos(photos = [], contextInput = {}) { + const context = normalizeListingContext(contextInput); + const base = heuristicAnalysis(context); + if (!photos.length || !process.env.OPENAI_API_KEY) return base; + + const content = photos.slice(0, 4).map(photo => ({ type: 'image_url', image_url: { url: fileToDataUrl(photo), detail: 'high' } })); + content.push({ + type: 'text', + text: `Analysiere diese Produktfotos für ein Second-Hand-Listing. Antworte ausschließlich als JSON mit den Feldern: category, garmentType, color, pattern, material, fit, style, age_group (adult|kids|unknown), suitable_for_model_photos (boolean), has_person (boolean), confidence, notes. Kontext: ${JSON.stringify(context)}. If a person is visible set has_person=true. If the item appears to be underwear, swimwear, lingerie, transparent erotic wear or child clothing, mark suitable_for_model_photos=false.` + }); + + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0.1, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Du bist eine strenge Safety- und Produktanalyse für einen Mode-Listing-Generator.' }, + { role: 'user', content }, + ], + max_tokens: 500, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed ? { ...base, ...parsed } : base; + } catch { + return base; + } +} + +export async function precheckAiModelPhotos(input = {}, settings = {}) { + const context = normalizeListingContext(input); + const photos = Array.isArray(input.photos) ? input.photos.filter(Boolean) : []; + const variants = clamp(Number(input.variants || settings.ai_model_photo_default_variants || 1), 1, 3); + const preset = ['studio', 'indoor', 'outdoor', 'mixed'].includes(input.preset) ? input.preset : (settings.ai_model_photo_default_preset || 'mixed'); + const mode = ['ghost_mannequin', 'flatlay'].includes(input.mode) ? input.mode : (settings.ai_model_photo_default_mode || 'flatlay'); + const flags = keywordFlags(context); + if (!photos.length) return { allowed: false, reason: 'Keine Fotos vorhanden.', context, variants, preset, mode, checks: [] }; + if (flags.blocked.length) return { allowed: false, reason: `Blockierter Produkttyp erkannt: ${flags.blocked[0]}.`, context, variants, preset, mode, checks: ['block_underwear'] }; + if (flags.kids.length) return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['block_kids'] }; + const analysis = await analyzeGarmentPhotos(photos, context); + if (String(analysis.age_group || '').toLowerCase() === 'kids') return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['vision_kids'], analysis }; + if (analysis.suitable_for_model_photos === false) return { allowed: false, reason: 'Der Artikel ist laut Sicherheitsprüfung nicht für AI-Model-Fotos geeignet.', context, variants, preset, mode, checks: ['vision_block'], analysis }; + const hay = `${context.title} ${context.category}`.toLowerCase(); + const looksLikeClothing = flags.clothing || CLOTHING_KEYWORDS.some(word => hay.includes(word)) || !!analysis.garmentType; + if (!looksLikeClothing) return { allowed: false, reason: 'AI-Model-Fotos sind aktuell nur für Kleidung und Modeartikel vorgesehen.', context, variants, preset, mode, checks: ['not_clothing'], analysis }; + return { allowed: true, reason: 'Freigegeben', context, variants, preset, mode, checks: ['adult_only', 'fully_clothed', 'non_sexual'], analysis }; +} + + +function buildVariantPrompt({ analysis, context, preset, variantIndex, mode, customPrompt = '', preserveLogos = true }) { + const options = PRESET_VARIANTS[preset] || PRESET_VARIANTS.mixed; + const scene = options[variantIndex % options.length]; + const productDetails = [ + analysis.garmentType || context.category || 'Kleidungsstück', + analysis.color || context.color || null, + analysis.pattern ? `Muster: ${analysis.pattern}` : null, + analysis.material ? `Material: ${analysis.material}` : null, + analysis.fit ? `Schnitt/Fit: ${analysis.fit}` : null, + analysis.style ? `Stil: ${analysis.style}` : null, + context.brand ? `Marke: ${context.brand}` : null, + context.size ? `Größe: ${context.size}` : null, + context.condition ? `Zustand: ${context.condition}` : null, + ].filter(Boolean).join(', '); + const logoInstruction = preserveLogos + ? 'Do not alter, remove, stylize, mirror, replace, blur or invent any visible brand logo, chest print, text, embroidery, patch, label, badge or trademark on the garment. Preserve every visible logo and print exactly as shown in the source photo.' + : 'Keep visible branding and prints plausible and unchanged where possible.'; + const customInstruction = String(customPrompt || '').trim() ? `Additional custom seller instruction: ${String(customPrompt || '').trim().slice(0, 500)}.` : ''; + + if (mode === 'ghost_mannequin') { + return [ + 'Generate a photorealistic apparel photo for a second-hand marketplace.', + 'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not replace it with a generic garment.', + logoInstruction, + 'Show the clothing item as a clean ghost mannequin presentation with no visible real person.', + 'The garment must look naturally worn-in-form but with an invisible mannequin / hollow-neck apparel-photo style.', + 'No visible skin, no nudity, no erotic styling, no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + 'Use a neutral realistic marketplace background and keep the full garment easy to inspect.', + variantIndex === 0 ? 'Use a clear front view.' : variantIndex === 1 ? 'Use a 3/4 angle or detail-friendly secondary angle.' : 'Use an alternate clean catalog angle.', + customInstruction, + ].filter(Boolean).join(' '); + } + + if (mode === 'flatlay') { + return [ + 'Generate a photorealistic flat lay marketplace photo for a clothing item.', + 'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not invent another item.', + logoInstruction, + 'No person should be present. Arrange the garment neatly on a clean, realistic surface.', + 'The item must be the focus, fully visible, symmetrical where appropriate, and suitable for second-hand listing use.', + 'No erotic styling, no lingerie presentation, and no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + variantIndex === 0 ? 'Use a clean straight-down hero shot.' : variantIndex === 1 ? 'Use a slightly styled flat lay with minimal accessories or folds only if tasteful.' : 'Use a third variation with another neat layout angle.', + customInstruction, + ].filter(Boolean).join(' '); + } + + return [ + 'Generate a photorealistic marketplace product image without any person.', + 'Use the uploaded product photo as the mandatory visual reference and preserve the item accurately.', + logoInstruction, + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + customInstruction, + ].filter(Boolean).join(' '); +} + +async function runOutputSafetyCheck(filePath) { + if (!process.env.OPENAI_API_KEY) return { safe: true, verdict: 'unchecked', notes: 'Keine zusätzliche API-Moderation konfiguriert.' }; + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Prüfe Modebilder streng auf Nacktheit, Sexualisierung, Minderjährige oder unzulässige Inhalte. Antworte nur als JSON.' }, + { role: 'user', content: [ + { type: 'image_url', image_url: { url: `data:image/png;base64,${readFileSync(filePath).toString('base64')}`, detail: 'low' } }, + { type: 'text', text: 'Antworte mit JSON: {"safe": boolean, "verdict": "safe|blocked", "notes": string}. Block if nudity, lingerie pose, erotic styling or minor-looking person is visible.' }, + ] }, + ], + max_tokens: 180, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed || { safe: true, verdict: 'safe', notes: 'Fallback safe.' }; + } catch { + return { safe: true, verdict: 'unchecked', notes: 'Moderation nicht verfügbar.' }; + } +} + +async function generateWithOpenAI(prompt, model) { + const response = await getOpenAIClient().images.generate({ model, prompt, size: '1024x1024', quality: 'medium' }); + return response.data?.[0]?.b64_json || null; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 90000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (error?.name === 'AbortError') throw new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`); + throw error; + } finally { + clearTimeout(timeout); + } +} + +function findImageValue(value) { + if (!value) return null; + if (typeof value === 'string') { + if (value.startsWith('data:image/')) return { type: 'base64', value: value.split(',')[1] || null }; + if (/^https?:\/\//i.test(value)) return { type: 'url', value }; + const dataMatch = value.match(/data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=]+)/i); + if (dataMatch) return { type: 'base64', value: dataMatch[1] }; + const markdownMatch = value.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i); + if (markdownMatch) return { type: 'url', value: markdownMatch[1] }; + return null; + } + if (Array.isArray(value)) { + for (const entry of value) { + const found = findImageValue(entry); + if (found) return found; + } + return null; + } + if (typeof value === 'object') { + if (typeof value.b64_json === 'string') return { type: 'base64', value: value.b64_json }; + const candidates = [ + value.url, + value.image_url?.url, + value.image_url, + value.source?.data, + value.data, + value.images, + value.content, + ]; + for (const candidate of candidates) { + const found = findImageValue(candidate); + if (found) return found; + } + } + return null; +} + +function extractOpenRouterImage(payload = {}) { + return findImageValue([ + payload?.data, + payload?.images, + payload?.choices?.[0]?.message?.images, + payload?.choices?.[0]?.message?.content, + payload?.output, + ]); +} + +async function downloadImageAsBase64(url) { + const response = await fetchWithTimeout(url, {}, 45000); + if (!response.ok) throw new Error(`Bild-Download fehlgeschlagen (${response.status})`); + return Buffer.from(await response.arrayBuffer()).toString('base64'); +} + +async function imageResultToBase64(result) { + if (!result?.value) return null; + return result.type === 'url' ? downloadImageAsBase64(result.value) : result.value; +} + +async function generateWithOpenRouterChat(prompt, candidate, sourceDataUrl = null) { + const content = []; + if (sourceDataUrl && candidate.supports_image_input) { + content.push({ type: 'image_url', image_url: { url: sourceDataUrl } }); + } + content.push({ type: 'text', text: prompt }); + + const baseBody = { + model: candidate.model, + modalities: ['image', 'text'], + messages: [{ role: 'user', content }], + stream: false, + }; + const requestBodies = [ + { ...baseBody, image_config: { aspect_ratio: '1:1' }, provider: { allow_fallbacks: true } }, + { ...baseBody, provider: { allow_fallbacks: true } }, + baseBody, + ]; + const errors = []; + + for (const body of requestBodies) { + const response = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify(body), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (response.ok) { + const result = extractOpenRouterImage(payload); + const base64 = await imageResultToBase64(result); + if (base64) return base64; + errors.push('Chat API antwortete erfolgreich, enthielt aber keine Bilddaten.'); + continue; + } + + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + errors.push(`Chat API ${response.status}: ${message}`); + if (![400, 404, 422].includes(response.status)) break; + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithOpenRouterImagesApi(prompt, candidate) { + const response = await fetchWithTimeout(OPENROUTER_IMAGES_URL, { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify({ model: candidate.model, prompt, size: '1024x1024', n: 1 }), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (!response.ok) { + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + throw new Error(`Images API ${response.status}: ${message}`); + } + const result = extractOpenRouterImage(payload); + return imageResultToBase64(result); +} + +async function generateWithOpenRouter(prompt, candidate, sourceDataUrl = null) { + const errors = []; + try { + const chatImage = await generateWithOpenRouterChat(prompt, candidate, sourceDataUrl); + if (chatImage) return chatImage; + errors.push('Chat API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Chat API fehlgeschlagen.'); + } + + try { + const apiImage = await generateWithOpenRouterImagesApi(prompt, candidate); + if (apiImage) return apiImage; + errors.push('Images API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Images API fehlgeschlagen.'); + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithProviderCandidate(prompt, candidate, sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) { + let result = {}; + if (candidate.provider === 'openrouter') { + result.base64 = await generateWithOpenRouter(prompt, candidate, sourceDataUrl); + result.reference_used = !!sourceDataUrl && candidate.supports_image_input !== false; + } else if (candidate.provider === 'local') { + result = await generateWithLocalFlux({ prompt, settings, mode, variantIndex, sourceDataUrl, onProgress }); + } else { + result.base64 = await generateWithOpenAI(prompt, candidate.model); + result.reference_used = false; + } + if (!result.base64) throw new Error(`${candidate.label || candidate.model} lieferte keine Bilddaten zurück.`); + return result; +} + +async function generateWithProviderFallback(prompt, candidates = [], sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) { + const attempts = []; + for (const candidate of candidates) { + try { + if (typeof onProgress === 'function') onProgress(4, `${candidate.label || candidate.model} wird vorbereitet …`); + const result = await generateWithProviderCandidate(prompt, candidate, sourceDataUrl, settings, mode, variantIndex, onProgress); + return { ...result, candidate, attempts }; + } catch (error) { + attempts.push({ + provider: candidate.provider, + model: candidate.model, + label: candidate.label, + error: error.message || 'Unbekannter Fehler', + }); + } + } + const summary = attempts.map(item => `${item.label || item.model}: ${item.error}`).join(' | '); + const error = new Error(summary || 'Alle Bildmodelle sind fehlgeschlagen.'); + error.attempts = attempts; + throw error; +} + + +export async function generateAiModelPhotos(input = {}, onProgress = null) { + const report = (progress, message, meta = {}) => { + if (typeof onProgress === 'function') onProgress(progress, message, meta); + }; + + report(5, 'Produktfotos und Sicherheitsregeln werden geprüft …'); + const precheck = await precheckAiModelPhotos(input, input.settings || {}); + if (!precheck.allowed) { + const error = new Error(precheck.reason || 'AI-Model-Fotos wurden blockiert.'); + error.code = 'AI_MODEL_PRECHECK_FAILED'; + error.precheck = precheck; + throw error; + } + + + report(12, 'Passender Bildprovider wird ausgewählt …'); + let providerCandidates = await resolveImageProviders(input.settings || {}); + const context = precheck.context; + const analysis = precheck.analysis || heuristicAnalysis(context); + const variants = precheck.variants; + const preset = precheck.preset; + const mode = precheck.mode || 'flatlay'; + const created = []; + const useSourceReference = String(input.settings?.ai_model_photo_use_source_reference ?? '1') !== '0'; + const preserveLogos = String(input.preserve_logos ?? input.settings?.ai_model_photo_preserve_logos ?? '1') !== '0'; + const customPrompt = String(input.custom_prompt || '').trim(); + const sourceDataUrl = useSourceReference && input.photos?.[0] ? fileToDataUrl(input.photos[0]) : null; + let lastProviderInfo = providerCandidates[0] || null; + const providerAttempts = []; + + for (let index = 0; index < variants; index += 1) { + const variantStart = 16 + Math.round((index / variants) * 72); + const variantEnd = 16 + Math.round(((index + 1) / variants) * 72); + const prompt = buildVariantPrompt({ analysis, context, preset, variantIndex: index, mode, customPrompt, preserveLogos }); + report(variantStart, `Variante ${index + 1} von ${variants} wird vorbereitet …`); + const generated = await generateWithProviderFallback( + prompt, + providerCandidates, + sourceDataUrl, + input.settings || {}, + mode, + index, + (providerProgress, message) => { + const scaled = variantStart + Math.round((Math.max(0, Math.min(100, Number(providerProgress || 0))) / 100) * Math.max(1, variantEnd - variantStart - 5)); + report(Math.min(91, scaled), `Variante ${index + 1}/${variants}: ${message}`); + }, + ); + const base64 = generated.base64; + const providerInfo = generated.candidate; + lastProviderInfo = providerInfo; + providerAttempts.push(...(generated.attempts || [])); + providerCandidates = [providerInfo, ...providerCandidates.filter(candidate => !(candidate.provider === providerInfo.provider && candidate.model === providerInfo.model))]; + + report(Math.max(variantStart, variantEnd - 4), `Variante ${index + 1}/${variants}: Sicherheitsprüfung läuft …`); + const filename = `ai-models/${randomUUID()}.png`; + const filePath = join(uploadsDir, filename); + writeFileSync(filePath, Buffer.from(base64, 'base64')); + + const moderation = await runOutputSafetyCheck(filePath); + if (!moderation.safe) continue; + created.push({ + filename, + prompt, + moderation, + variant_index: index + 1, + preset, + source_photo: input.photos?.[0] || null, + public_url: `/uploads/${filename}`, + provider: providerInfo.provider, + provider_label: providerInfo.label, + provider_model: providerInfo.model, + provider_free: providerInfo.free, + mode, + reference_used: generated.reference_used ?? useSourceReference, + }); + report(variantEnd, `Variante ${index + 1} von ${variants} ist fertig.`); + } + + if (!created.length) throw new Error('Es konnten keine sicheren AI-Model-Fotos erzeugt werden.'); + report(92, 'AI-Bilder werden für das Listing vorbereitet …'); + + return { + precheck, + provider: lastProviderInfo, + provider_attempts: providerAttempts, + mode, + reference_used: useSourceReference, + prompt_summary: `${analysis.garmentType || context.category || 'Kleidungsstück'} · ${mode} · ${preset} · ${created.length} Variante(n)${useSourceReference ? ' · mit Produktreferenz' : ''}${preserveLogos ? ' · Logos geschützt' : ''}${customPrompt ? ' · mit Benutzerprompt' : ''}`, + photos: created, + }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openai.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openai.mjs new file mode 100644 index 0000000..d32da1c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openai.mjs @@ -0,0 +1,45 @@ +import OpenAI from 'openai'; + +let client; + +function getClient() { + if (!client) client = new OpenAI(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}`, detail: 'high' }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().chat.completions.create({ + model: 'gpt-4o', + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }); + + const text = response.choices[0].message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt) { + const response = await getClient().chat.completions.create({ + model: 'gpt-4o', + max_tokens: 1400, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }); + return String(response.choices?.[0]?.message?.content || '').trim(); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openrouter.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openrouter.mjs new file mode 100644 index 0000000..54a89c6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-openrouter.mjs @@ -0,0 +1,152 @@ +const OPENROUTER_BASE = 'https://openrouter.ai/api/v1'; + +export const MODELS = [ + { id: 'google/gemini-3.1-flash-lite-image-20260630', name: 'Gemini 3.1 Flash Lite (~0)', free: false, priority: 1 }, + { id: 'google/gemini-3.1-flash-image-20260528', name: 'Gemini 3.1 Flash Image (~0)', free: false, priority: 2 }, + { id: 'google/gemini-3-pro-image-20260528', name: 'Gemini 3 Pro Image', free: false, priority: 3 }, + { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', name: 'Nemotron 3 Nano Omni (free)', free: true, priority: 4 }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)', free: true, priority: 5 }, + { id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)', free: true, priority: 6 }, + { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 (free)', free: true, priority: 7 }, + { id: 'qwen/qwen3-235b-a22b:free', name: 'Qwen 3 235B (free)', free: true, priority: 8 }, +]; + +function extractJSON(text) { + if (!text) return null; + // Try direct parse first + try { return JSON.parse(text); } catch {} + // Find outermost { ... } + let depth = 0, start = -1; + for (let i = 0; i < text.length; i++) { + if (text[i] === '{') { if (depth === 0) start = i; depth++; } + else if (text[i] === '}') { depth--; if (depth === 0 && start >= 0) { + try { return JSON.parse(text.substring(start, i + 1)); } catch {} + }} + } + // Try fixing common issues: trailing commas, unescaped newlines + const match = text.match(/\{[\s\S]*\}/); + if (match) { + let cleaned = match[0] + .replace(/,\s*([}\]])/g, '$1') // trailing commas + .replace(/[\x00-\x1f]/g, m => // control chars in strings + m === '\n' ? '\\n' : m === '\r' ? '\\r' : m === '\t' ? '\\t' : '' + ); + try { return JSON.parse(cleaned); } catch {} + } + return null; +} + +async function callModel(modelId, images, systemPrompt) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY nicht gesetzt'); + + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}` }, + }); + } + content.push({ + type: 'text', + text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + }); + + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'http://localhost:8124', + 'X-Title': 'Vendoo', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${res.status}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content; + if (!text) throw new Error('Leere Antwort'); + + const parsed = extractJSON(text); + if (!parsed) throw new Error('Kein valides JSON in Antwort'); + return parsed; +} + + +async function callTextModel(modelId, systemPrompt, userPrompt) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY nicht gesetzt'); + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'http://localhost:8124', + 'X-Title': 'Vendoo', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 1400, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${res.status}`); + } + const data = await res.json(); + return String(data.choices?.[0]?.message?.content || '').trim(); +} + +export async function generateText(systemPrompt, userPrompt, model) { + if (model) return callTextModel(model, systemPrompt, userPrompt); + const errors = []; + for (const entry of [...MODELS].sort((a, b) => a.priority - b.priority)) { + try { return await callTextModel(entry.id, systemPrompt, userPrompt); } + catch (error) { + errors.push(`${entry.name}: ${error.message}`); + if (/API_KEY|nicht gesetzt/i.test(error.message)) break; + } + } + throw new Error(`Kein OpenRouter-Modell konnte den Prompt verbessern: ${errors.join(' | ')}`); +} + +export async function generate(images, systemPrompt, model) { + if (model) { + return callModel(model, images, systemPrompt); + } + + const ordered = [...MODELS].sort((a, b) => a.priority - b.priority); + const errors = []; + + for (const m of ordered) { + try { + console.log(`OpenRouter: Versuche ${m.name}...`); + const result = await callModel(m.id, images, systemPrompt); + console.log(`OpenRouter: Erfolg mit ${m.name}`); + return result; + } catch (err) { + const msg = err.message; + console.log(`OpenRouter: ${m.name} fehlgeschlagen: ${msg}`); + errors.push(`${m.name}: ${msg}`); + if (msg.includes('API_KEY') || msg.includes('nicht gesetzt')) break; + } + } + + throw new Error(`Alle Modelle fehlgeschlagen:\n${errors.join('\n')}`); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-router.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-router.mjs new file mode 100644 index 0000000..8a25007 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ai-router.mjs @@ -0,0 +1,40 @@ +import * as claude from './ai-claude.mjs'; +import * as openai from './ai-openai.mjs'; +import * as openrouter from './ai-openrouter.mjs'; +import * as local from './ai-local.mjs'; + +const providers = { claude, openai, openrouter, local }; + +export async function generate(provider, images, systemPrompt, model) { + const ai = providers[provider]; + if (!ai) throw new Error(`Unknown AI provider: ${provider}`); + return ai.generate(images, systemPrompt, model); +} + +export async function generateText(provider, systemPrompt, userPrompt, model) { + const ai = providers[provider]; + if (!ai) throw new Error(`Unknown AI provider: ${provider}`); + if (typeof ai.generateText !== 'function') throw new Error(`Textgenerierung wird von ${provider} nicht unterstützt.`); + return ai.generateText(systemPrompt, userPrompt, model); +} + +export function getProviders() { + return [ + { id: 'claude', name: 'Claude', hasModels: false, needsKey: 'ANTHROPIC_API_KEY' }, + { id: 'openai', name: 'OpenAI', hasModels: false, needsKey: 'OPENAI_API_KEY' }, + { + id: 'openrouter', + name: 'OpenRouter', + hasModels: true, + models: openrouter.MODELS, + needsKey: 'OPENROUTER_API_KEY', + }, + { + id: 'local', + name: 'Lokal (Ollama)', + hasModels: true, + models: local.MODELS, + needsKey: null, + }, + ]; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/archive.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/archive.mjs new file mode 100644 index 0000000..d540d74 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/archive.mjs @@ -0,0 +1,78 @@ +import { inflateRawSync } from 'zlib'; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; + +function normalizeEntryName(name) { + const normalized = String(name || '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || normalized.endsWith('/')) return normalized; + if (normalized.includes('\0') || normalized.split('/').some(part => part === '..')) { + throw new Error(`Unsicherer ZIP-Pfad: ${name}`); + } + return normalized; +} + +function findEndOfCentralDirectory(buffer) { + const min = Math.max(0, buffer.length - 0xffff - 22); + for (let offset = buffer.length - 22; offset >= min; offset--) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset; + } + throw new Error('ZIP-Endverzeichnis wurde nicht gefunden.'); +} + +export function readZipEntries(buffer, { maxEntries = 10000, maxUncompressedBytes = 8 * 1024 * 1024 * 1024 } = {}) { + if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer || []); + const eocd = findEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocd + 10); + const centralSize = buffer.readUInt32LE(eocd + 12); + const centralOffset = buffer.readUInt32LE(eocd + 16); + if (entryCount > maxEntries) throw new Error(`ZIP enthält zu viele Dateien (${entryCount}).`); + if (centralOffset + centralSize > buffer.length) throw new Error('ZIP-Zentralverzeichnis ist beschädigt.'); + + const entries = []; + let offset = centralOffset; + let totalUncompressed = 0; + for (let index = 0; index < entryCount; index++) { + if (buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) throw new Error('ZIP-Zentralverzeichnis ist ungültig.'); + const flags = buffer.readUInt16LE(offset + 8); + const method = buffer.readUInt16LE(offset + 10); + const crc = buffer.readUInt32LE(offset + 16); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localOffset = buffer.readUInt32LE(offset + 42); + const nameStart = offset + 46; + const name = normalizeEntryName(buffer.subarray(nameStart, nameStart + nameLength).toString((flags & 0x0800) ? 'utf8' : 'utf8')); + totalUncompressed += uncompressedSize; + if (totalUncompressed > maxUncompressedBytes) throw new Error('ZIP ist nach dem Entpacken zu groß.'); + entries.push({ name, method, crc, compressedSize, uncompressedSize, localOffset, directory: name.endsWith('/') }); + offset = nameStart + nameLength + extraLength + commentLength; + } + return entries; +} + +export function extractZipEntry(buffer, entry) { + if (entry.directory) return Buffer.alloc(0); + const offset = entry.localOffset; + if (buffer.readUInt32LE(offset) !== LOCAL_SIGNATURE) throw new Error(`Lokaler ZIP-Header fehlt: ${entry.name}`); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + nameLength + extraLength; + const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize); + let data; + if (entry.method === 0) data = Buffer.from(compressed); + else if (entry.method === 8) data = inflateRawSync(compressed); + else throw new Error(`Nicht unterstützte ZIP-Kompression (${entry.method}) für ${entry.name}.`); + if (data.length !== entry.uncompressedSize) throw new Error(`ZIP-Größe stimmt nicht: ${entry.name}`); + return data; +} + +export function readZip(buffer, options) { + const entries = readZipEntries(buffer, options); + const files = new Map(); + for (const entry of entries) if (!entry.directory) files.set(entry.name, extractZipEntry(buffer, entry)); + return { entries, files }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/auth.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/auth.mjs new file mode 100644 index 0000000..3d8a951 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/auth.mjs @@ -0,0 +1,377 @@ +import { db } from './db.mjs'; +import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +// --- Schema --- +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'editor', + active INTEGER NOT NULL DEFAULT 1, + avatar_color TEXT, + last_active_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + invited_by INTEGER, + FOREIGN KEY (invited_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + ip TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + type TEXT NOT NULL DEFAULT 'login', + role TEXT DEFAULT 'editor', + used INTEGER NOT NULL DEFAULT 0, + expires_at TEXT NOT NULL, + created_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (created_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS login_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + ip TEXT, + user_agent TEXT, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +// Migration: add password_hash column if missing +try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN locked_until TEXT"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN password_changed_at TEXT"); } catch {} +try { db.exec("ALTER TABLE sessions ADD COLUMN last_seen_at TEXT"); } catch {} +try { db.exec("ALTER TABLE sessions ADD COLUMN revoked_at TEXT"); } catch {} +try { db.exec("UPDATE sessions SET last_seen_at = COALESCE(last_seen_at, created_at)"); } catch {} + +const AVATAR_COLORS = [ + '#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b', + '#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c', +]; + +const SESSION_DURATION_MS = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000; +const SESSION_IDLE_MS = Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)) * 60 * 1000; +const SESSION_TOUCH_INTERVAL_MS = 60 * 1000; +const LOGIN_TOKEN_EXPIRY_MIN = 15; +const INVITE_TOKEN_EXPIRY_HOURS = 48; + +function hashToken(token) { + return createHash('sha256').update(token).digest('hex'); +} + +function generateToken() { + return randomBytes(32).toString('hex'); +} + +function randomAvatarColor() { + return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; +} + +// --- Password Hashing (scrypt) --- +export function hashPassword(password) { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password, storedHash) { + if (!storedHash) return false; + const [salt, hash] = storedHash.split(':'); + if (!salt || !hash) return false; + const hashBuf = Buffer.from(hash, 'hex'); + const supplied = scryptSync(password, salt, 64); + return timingSafeEqual(hashBuf, supplied); +} + +// --- Users --- +export function createUser(email, name, role = 'editor', invitedBy = null, password = null) { + role = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor'; + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) throw new Error('User existiert bereits'); + const color = randomAvatarColor(); + const pwHash = password ? hashPassword(password) : null; + const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash); + return getUser(r.lastInsertRowid); +} + +export function setPassword(userId, password) { + const pwHash = hashPassword(password); + db.prepare("UPDATE users SET password_hash = ?, password_changed_at = datetime('now'), failed_login_count = 0, locked_until = NULL WHERE id = ?").run(pwHash, userId); + destroyUserSessions(userId); +} + +export function validatePasswordStrength(password) { + const value = String(password || ''); + const problems = []; + if (value.length < 10) problems.push('mindestens 10 Zeichen'); + if (!/[a-z]/.test(value)) problems.push('einen Kleinbuchstaben'); + if (!/[A-Z]/.test(value)) problems.push('einen Großbuchstaben'); + if (!/\d/.test(value)) problems.push('eine Zahl'); + if (!/[^A-Za-z0-9]/.test(value)) problems.push('ein Sonderzeichen'); + return { valid: problems.length === 0, problems }; +} + +export function registerFailedLogin(userId, maxAttempts = 8, lockMinutes = 15) { + if (!userId) return null; + const user = getUser(userId); + if (!user) return null; + const count = Number(user.failed_login_count || 0) + 1; + if (count >= maxAttempts) { + const lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000).toISOString(); + db.prepare('UPDATE users SET failed_login_count = 0, locked_until = ? WHERE id = ?').run(lockedUntil, userId); + destroyUserSessions(userId); + return { locked: true, lockedUntil }; + } + db.prepare('UPDATE users SET failed_login_count = ? WHERE id = ?').run(count, userId); + return { locked: false, remaining: Math.max(0, maxAttempts - count) }; +} + +export function clearFailedLogins(userId) { + if (!userId) return; + db.prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?').run(userId); +} + +export function isUserLocked(user) { + if (!user?.locked_until) return false; + const until = Date.parse(user.locked_until); + if (!Number.isFinite(until) || until <= Date.now()) { + clearFailedLogins(user.id); + return false; + } + return true; +} + +export function getUser(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); +} + +export function getUserByEmail(email) { + return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email); +} + +export function getUsers() { + return db.prepare("SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, locked_until, failed_login_count, CASE WHEN password_hash IS NOT NULL AND password_hash != '' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at").all(); +} + +export function updateUser(id, data) { + const allowed = ['name', 'role', 'active', 'avatar_color']; + const fields = [], values = []; + for (const [k, rawValue] of Object.entries(data)) { + if (!allowed.includes(k) || rawValue === undefined) continue; + const v = k === 'role' ? (['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(rawValue) ? rawValue : 'viewer') : rawValue; + fields.push(`${k} = ?`); values.push(v); + } + if (!fields.length) return getUsers().find(user => Number(user.id) === Number(id)) || null; + values.push(id); + db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getUsers().find(user => Number(user.id) === Number(id)) || null; +} + +export function deleteUser(id) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); +} + +export function touchUserActivity(userId) { + db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId); +} + +export function getUserCount() { + return db.prepare('SELECT COUNT(*) as count FROM users').get().count; +} + +// --- Sessions --- +export function createSession(userId, ip, userAgent) { + const token = generateToken(); + const hash = hashToken(token); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS).toISOString(); + const lastSeenAt = now.toISOString(); + db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)').run(userId, hash, ip, userAgent || '', expiresAt, lastSeenAt); + return { token, expiresAt }; +} + +export function validateSession(token) { + if (!token) return null; + const hash = hashToken(token); + const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color, u.locked_until FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now')").get(hash); + if (!session || !session.active || isUserLocked(session)) return null; + const lastSeen = Date.parse(session.last_seen_at || session.created_at || 0); + if (Number.isFinite(lastSeen) && Date.now() - lastSeen > SESSION_IDLE_MS) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(session.id); + return null; + } + if (!Number.isFinite(lastSeen) || Date.now() - lastSeen >= SESSION_TOUCH_INTERVAL_MS) { + const now = new Date().toISOString(); + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run(now, session.id); + session.last_seen_at = now; + touchUserActivity(session.user_id); + } + return session; +} + +export function destroySession(token) { + const hash = hashToken(token); + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE token_hash = ?").run(hash); +} + +export function destroySessionById(id) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(id); +} + +export function destroyUserSessions(userId) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL").run(userId); +} + +export function getUserSessions(userId) { + return db.prepare("SELECT id, ip, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId); +} + +export function getAllActiveSessions() { + return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.revoked_at IS NULL AND s.expires_at > datetime('now') ORDER BY s.created_at DESC").all(); +} + +// clean expired sessions periodically +export function cleanExpiredSessions() { + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now') OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', '-7 days'))").run(); +} + +// --- Auth Tokens (Magic Links) --- +export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) { + const token = generateToken(); + const hash = hashToken(token); + const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN; + const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString(); + + // invalidate previous unused tokens of same type for same email + db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type); + + db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy); + return { token, expiresAt }; +} + +export function validateAuthToken(token) { + const hash = hashToken(token); + const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash); + if (!row) return null; + db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id); + return row; +} + +// --- Rate Limiting --- +const rateLimits = new Map(); + +export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) { + const now = Date.now(); + const entry = rateLimits.get(key); + if (!entry) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + if (now - entry.firstAttempt > windowMs) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + entry.count++; + if (entry.count > maxAttempts) { + const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000); + return { allowed: false, remaining: 0, retryAfter }; + } + return { allowed: true, remaining: maxAttempts - entry.count }; +} + +// --- CSRF --- +export function generateCsrfToken() { + return randomBytes(24).toString('hex'); +} + +// --- Audit Log --- +export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) { + db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip); +} + +export function getAuditLog(limit = 100, offset = 0) { + return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset); +} + +export function searchAuditLog({ limit = 100, offset = 0, userId = null, action = '', from = '', to = '', query = '' } = {}) { + const clauses = []; + const values = []; + if (userId) { clauses.push('user_id = ?'); values.push(Number(userId)); } + if (action) { clauses.push('action LIKE ?'); values.push(`%${String(action).slice(0, 80)}%`); } + if (from) { clauses.push('created_at >= ?'); values.push(String(from).slice(0, 30)); } + if (to) { clauses.push('created_at <= ?'); values.push(String(to).slice(0, 30)); } + if (query) { + const q = `%${String(query).slice(0, 120)}%`; + clauses.push('(user_email LIKE ? OR action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR details LIKE ? OR ip LIKE ?)'); + values.push(q, q, q, q, q, q); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 100)); + const safeOffset = Math.max(0, Number(offset) || 0); + const rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...values, safeLimit, safeOffset); + const total = db.prepare(`SELECT COUNT(*) AS count FROM audit_log ${where}`).get(...values)?.count || 0; + return { rows, total, limit: safeLimit, offset: safeOffset }; +} + +export function getAuditLogForUser(userId, limit = 50) { + return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +// --- Login History --- +export function recordLogin(userId, ip, userAgent, success = true) { + db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0); +} + +export function getLoginHistory(userId, limit = 20) { + return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +export function getRecentFailedLogins(email, windowMinutes = 15) { + const user = getUserByEmail(email); + if (!user) return 0; + const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`); + return r.count; +} + +// --- Setup: ensure at least one admin exists --- +export function ensureAdminExists() { + const count = getUserCount(); + return count === 0; +} + +export function isSetupMode() { + return getUserCount() === 0; +} + +// Clean expired sessions every 10 minutes +setInterval(cleanExpiredSessions, 10 * 60 * 1000); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/categories.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/categories.mjs new file mode 100644 index 0000000..708fc0c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/categories.mjs @@ -0,0 +1,359 @@ +const CATEGORIES = { + vinted: [ + { id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, { id: 4, name: 'Kleider' }, { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, { id: 8, name: 'Jeans' }, { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, { id: 11, name: 'Sportbekleidung' }, { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 1226, name: 'Shorts' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, { id: 20, name: 'Sandalen' }, { id: 21, name: 'Ballerinas' }, + { id: 23, name: 'Sportschuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, { id: 26, name: 'Umhängetaschen' }, { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches' }, { id: 29, name: 'Geldbörsen & Etuis' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, { id: 32, name: 'Uhren' }, { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, { id: 35, name: 'Gürtel' }, { id: 36, name: 'Mützen & Hüte' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, { id: 39, name: 'Hautpflege' }, { id: 41, name: 'Parfum' }, + ]}, + ]}, + { id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, { id: 53, name: 'Hosen' }, { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, { id: 56, name: 'Jacken & Mäntel' }, { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, { id: 65, name: 'Sandalen' }, { id: 66, name: 'Sportschuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, { id: 69, name: 'Umhängetaschen' }, { id: 71, name: 'Geldbörsen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, { id: 73, name: 'Sonnenbrillen' }, { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, { id: 76, name: 'Mützen & Hüte' }, { id: 78, name: 'Schmuck' }, + ]}, + ]}, + { id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, { id: 104, name: 'Jacken & Mäntel' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, { id: 113, name: 'Jacken & Mäntel' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, { id: 131, name: 'Stiefel' }, { id: 132, name: 'Sandalen' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen' }, { id: 142, name: 'Bausteine' }, { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + ]}, + ]}, + { id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, { id: 201, name: 'Kerzen' }, { id: 202, name: 'Vasen' }, + ]}, + { id: 245, name: 'Küche', children: [ + { id: 210, name: 'Geschirr' }, { id: 211, name: 'Gläser & Tassen' }, { id: 213, name: 'Küchengeräte' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, { id: 222, name: 'Kissen & Decken' }, { id: 223, name: 'Teppiche' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, { id: 231, name: 'Tische' }, { id: 232, name: 'Stühle' }, + ]}, + ]}, + { id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane' }, { id: 301, name: 'Sachbücher' }, { id: 303, name: 'Comics & Manga' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, { id: 321, name: 'Konsolen' }, { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport', children: [ + { id: 330, name: 'Fitnessgeräte' }, { id: 331, name: 'Fahrräder' }, { id: 332, name: 'Camping' }, + ]}, + ]}, + { id: 1210, name: 'Elektronik', children: [ + { id: 253, name: 'Handys', children: [ + { id: 400, name: 'iPhones' }, { id: 401, name: 'Samsung' }, { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & Zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, { id: 411, name: 'Tablets' }, { id: 414, name: 'NAS & Server' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, { id: 431, name: 'Lautsprecher' }, + ]}, + ]}, + ], + + 'ebay-ka': [ + { id: 'mode', name: 'Mode & Beauty', children: [ + { id: 'damen', name: 'Damenmode', children: [ + { id: 'dk1', name: 'Oberteile & T-Shirts' }, { id: 'dk2', name: 'Kleider' }, + { id: 'dk3', name: 'Hosen & Jeans' }, { id: 'dk4', name: 'Jacken & Mäntel' }, + { id: 'dk5', name: 'Pullover & Strickwaren' }, { id: 'dk6', name: 'Schuhe' }, + { id: 'dk7', name: 'Taschen' }, { id: 'dk8', name: 'Accessoires' }, + ]}, + { id: 'herren', name: 'Herrenmode', children: [ + { id: 'hk1', name: 'T-Shirts & Hemden' }, { id: 'hk2', name: 'Hosen & Jeans' }, + { id: 'hk3', name: 'Jacken & Mäntel' }, { id: 'hk4', name: 'Pullover & Hoodies' }, + { id: 'hk5', name: 'Schuhe' }, { id: 'hk6', name: 'Accessoires' }, + ]}, + { id: 'beauty', name: 'Beauty & Gesundheit', children: [ + { id: 'bk1', name: 'Parfum' }, { id: 'bk2', name: 'Hautpflege' }, { id: 'bk3', name: 'Makeup' }, + ]}, + ]}, + { id: 'elektronik', name: 'Elektronik', children: [ + { id: 'handy', name: 'Handy & Telefon', children: [ + { id: 'ek1', name: 'Smartphones' }, { id: 'ek2', name: 'Zubehör' }, + ]}, + { id: 'pc', name: 'PC & Zubehör', children: [ + { id: 'ek3', name: 'Notebooks & Laptops' }, { id: 'ek4', name: 'PCs & Desktops' }, + { id: 'ek5', name: 'Monitore' }, { id: 'ek6', name: 'Netzwerk & Server' }, + { id: 'ek7', name: 'Drucker & Scanner' }, { id: 'ek8', name: 'Zubehör' }, + ]}, + { id: 'audio', name: 'Audio & HiFi', children: [ + { id: 'ek9', name: 'Kopfhörer' }, { id: 'ek10', name: 'Lautsprecher' }, { id: 'ek11', name: 'HiFi-Anlagen' }, + ]}, + { id: 'foto', name: 'Foto & Kamera', children: [ + { id: 'ek12', name: 'Digitalkameras' }, { id: 'ek13', name: 'Objektive' }, { id: 'ek14', name: 'Zubehör' }, + ]}, + { id: 'tv', name: 'TV & Video', children: [ + { id: 'ek15', name: 'Fernseher' }, { id: 'ek16', name: 'Streaming' }, + ]}, + { id: 'konsolen', name: 'Konsolen & Spiele', children: [ + { id: 'ek17', name: 'Konsolen' }, { id: 'ek18', name: 'Spiele' }, { id: 'ek19', name: 'Zubehör' }, + ]}, + ]}, + { id: 'haus', name: 'Haus & Garten', children: [ + { id: 'moebel', name: 'Möbel', children: [ + { id: 'hk1', name: 'Tische' }, { id: 'hk2', name: 'Stühle' }, { id: 'hk3', name: 'Regale & Schränke' }, + ]}, + { id: 'deko', name: 'Dekoration', children: [ + { id: 'hk4', name: 'Bilder & Poster' }, { id: 'hk5', name: 'Vasen & Kerzen' }, + ]}, + { id: 'kueche', name: 'Küche', children: [ + { id: 'hk6', name: 'Geschirr' }, { id: 'hk7', name: 'Geräte' }, + ]}, + { id: 'garten', name: 'Garten', children: [ + { id: 'hk8', name: 'Gartenmöbel' }, { id: 'hk9', name: 'Geräte & Werkzeug' }, + ]}, + ]}, + { id: 'kinder', name: 'Familie, Kind & Baby', children: [ + { id: 'kkleidung', name: 'Kinderkleidung', children: [ + { id: 'kk1', name: 'Mädchen' }, { id: 'kk2', name: 'Jungen' }, { id: 'kk3', name: 'Baby' }, + ]}, + { id: 'spielzeug', name: 'Spielzeug', children: [ + { id: 'kk4', name: 'Lego & Bausteine' }, { id: 'kk5', name: 'Puppen & Figuren' }, + { id: 'kk6', name: 'Brettspiele' }, { id: 'kk7', name: 'Outdoor-Spielzeug' }, + ]}, + ]}, + { id: 'freizeit', name: 'Freizeit & Sport', children: [ + { id: 'sport', name: 'Sport', children: [ + { id: 'fk1', name: 'Fitnessgeräte' }, { id: 'fk2', name: 'Fahrräder' }, + { id: 'fk3', name: 'Wintersport' }, { id: 'fk4', name: 'Ballsport' }, + ]}, + { id: 'buecher', name: 'Bücher & Medien', children: [ + { id: 'fk5', name: 'Bücher' }, { id: 'fk6', name: 'CDs & Vinyl' }, { id: 'fk7', name: 'DVDs & Blu-ray' }, + ]}, + ]}, + { id: 'auto', name: 'Auto, Rad & Boot', children: [ + { id: 'azubehoer', name: 'Zubehör & Teile', children: [ + { id: 'ak1', name: 'Reifen & Felgen' }, { id: 'ak2', name: 'Ersatzteile' }, + { id: 'ak3', name: 'Tuning & Styling' }, + ]}, + ]}, + ], + + 'ebay-de': [ + { id: 'fashion', name: 'Kleidung & Accessoires', children: [ + { id: 'ed-damen', name: 'Damenbekleidung', children: [ + { id: 'ed1', name: 'Tops & Shirts' }, { id: 'ed2', name: 'Kleider' }, + { id: 'ed3', name: 'Hosen' }, { id: 'ed4', name: 'Jacken & Mäntel' }, + { id: 'ed5', name: 'Pullover' }, { id: 'ed6', name: 'Röcke' }, + ]}, + { id: 'ed-herren', name: 'Herrenbekleidung', children: [ + { id: 'ed7', name: 'T-Shirts' }, { id: 'ed8', name: 'Hemden' }, + { id: 'ed9', name: 'Hosen' }, { id: 'ed10', name: 'Jacken & Mäntel' }, + { id: 'ed11', name: 'Pullover & Hoodies' }, + ]}, + { id: 'ed-schuhe', name: 'Schuhe', children: [ + { id: 'ed12', name: 'Sneaker' }, { id: 'ed13', name: 'Stiefel' }, + { id: 'ed14', name: 'Sandalen' }, { id: 'ed15', name: 'Businessschuhe' }, + ]}, + { id: 'ed-acc', name: 'Accessoires', children: [ + { id: 'ed16', name: 'Uhren' }, { id: 'ed17', name: 'Schmuck' }, + { id: 'ed18', name: 'Taschen' }, { id: 'ed19', name: 'Sonnenbrillen' }, + ]}, + ]}, + { id: 'computer', name: 'Computer, Tablets & Netzwerk', children: [ + { id: 'ed-pc', name: 'Desktops & All-in-Ones', children: [ + { id: 'ed20', name: 'PC-Systeme' }, { id: 'ed21', name: 'Komponenten' }, + ]}, + { id: 'ed-nb', name: 'Notebooks & Tablets', children: [ + { id: 'ed22', name: 'Notebooks' }, { id: 'ed23', name: 'Tablets' }, + ]}, + { id: 'ed-nw', name: 'Netzwerk', children: [ + { id: 'ed24', name: 'Router & Modems' }, { id: 'ed25', name: 'NAS-Server' }, + { id: 'ed26', name: 'Switches & Hubs' }, + ]}, + { id: 'ed-zubehoer', name: 'Zubehör', children: [ + { id: 'ed27', name: 'Tastaturen & Mäuse' }, { id: 'ed28', name: 'Monitore' }, + { id: 'ed29', name: 'Speicher' }, { id: 'ed30', name: 'Kabel & Adapter' }, + ]}, + ]}, + { id: 'handy', name: 'Handys & Kommunikation', children: [ + { id: 'ed-phones', name: 'Smartphones', children: [ + { id: 'ed31', name: 'Apple iPhone' }, { id: 'ed32', name: 'Samsung Galaxy' }, + { id: 'ed33', name: 'Sonstige' }, + ]}, + { id: 'ed-phzub', name: 'Zubehör', children: [ + { id: 'ed34', name: 'Hüllen & Cover' }, { id: 'ed35', name: 'Ladegeräte' }, + { id: 'ed36', name: 'Displayschutz' }, + ]}, + ]}, + { id: 'haus', name: 'Möbel & Wohnen', children: [ + { id: 'ed-moebel', name: 'Möbel', children: [ + { id: 'ed40', name: 'Tische' }, { id: 'ed41', name: 'Stühle' }, + { id: 'ed42', name: 'Regale' }, { id: 'ed43', name: 'Betten' }, + ]}, + { id: 'ed-deko', name: 'Dekoration', children: [ + { id: 'ed44', name: 'Bilder' }, { id: 'ed45', name: 'Kerzen' }, { id: 'ed46', name: 'Vasen' }, + ]}, + ]}, + { id: 'sport', name: 'Sporting Goods', children: [ + { id: 'ed-fitness', name: 'Fitness', children: [ + { id: 'ed50', name: 'Fitnessgeräte' }, { id: 'ed51', name: 'Laufbänder' }, + ]}, + { id: 'ed-outdoor', name: 'Camping & Outdoor', children: [ + { id: 'ed52', name: 'Zelte' }, { id: 'ed53', name: 'Rucksäcke' }, + ]}, + ]}, + ], + + etsy: [ + { id: 'jewel', name: 'Schmuck', children: [ + { id: 'et-rings', name: 'Ringe' }, { id: 'et-necklaces', name: 'Ketten' }, + { id: 'et-earrings', name: 'Ohrringe' }, { id: 'et-bracelets', name: 'Armbänder' }, + ]}, + { id: 'clothing', name: 'Kleidung', children: [ + { id: 'et-dresses', name: 'Kleider' }, { id: 'et-tops', name: 'Tops & Shirts' }, + { id: 'et-outerwear', name: 'Jacken & Mäntel' }, { id: 'et-pants', name: 'Hosen' }, + { id: 'et-shoes', name: 'Schuhe' }, { id: 'et-accessories', name: 'Accessoires' }, + ]}, + { id: 'home', name: 'Haus & Wohnen', children: [ + { id: 'et-deco', name: 'Wanddeko & Kunst' }, { id: 'et-furniture', name: 'Möbel' }, + { id: 'et-kitchen', name: 'Küche & Essen' }, { id: 'et-textiles', name: 'Textilien' }, + { id: 'et-candles', name: 'Kerzen & Duft' }, + ]}, + { id: 'vintage', name: 'Vintage', children: [ + { id: 'et-vclothing', name: 'Vintage Kleidung' }, { id: 'et-vjewelry', name: 'Vintage Schmuck' }, + { id: 'et-vhome', name: 'Vintage Haus & Wohnen' }, { id: 'et-vcollect', name: 'Sammlerstücke' }, + ]}, + { id: 'craft', name: 'Bastelmaterial', children: [ + { id: 'et-fabric', name: 'Stoffe' }, { id: 'et-beads', name: 'Perlen & Zubehör' }, + { id: 'et-tools', name: 'Werkzeug' }, { id: 'et-patterns', name: 'Schnittmuster' }, + ]}, + { id: 'art', name: 'Kunst', children: [ + { id: 'et-prints', name: 'Drucke & Poster' }, { id: 'et-painting', name: 'Gemälde' }, + { id: 'et-photo', name: 'Fotografie' }, { id: 'et-sculpture', name: 'Skulpturen' }, + ]}, + { id: 'gifts', name: 'Geschenke', children: [ + { id: 'et-personal', name: 'Personalisiert' }, { id: 'et-party', name: 'Party & Feier' }, + ]}, + { id: 'bags', name: 'Taschen & Geldbörsen', children: [ + { id: 'et-handbags', name: 'Handtaschen' }, { id: 'et-backpacks', name: 'Rucksäcke' }, + { id: 'et-wallets', name: 'Geldbörsen' }, + ]}, + { id: 'electronics', name: 'Elektronik & Zubehör', children: [ + { id: 'et-phonecases', name: 'Handyhüllen' }, { id: 'et-laptopacc', name: 'Laptop-Zubehör' }, + { id: 'et-gadgets', name: 'Gadgets' }, + ]}, + ], +}; + +function flattenTree(tree, path) { + const result = []; + for (const node of tree) { + const currentPath = [...(path || []), node.name]; + if (node.children) { + for (const child of node.children) { + const childPath = [...currentPath, child.name]; + if (child.children) { + for (const leaf of child.children) { + result.push({ + id: leaf.id, name: leaf.name, + path: [...childPath, leaf.name], + pathStr: [...childPath, leaf.name].join(' > '), + search: [...childPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: child.id, name: child.name, + path: childPath, + pathStr: childPath.join(' > '), + search: childPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +const flatCache = {}; + +export function getCategories(platformId) { + return CATEGORIES[platformId] || []; +} + +export function getCategoriesFlat(platformId) { + if (!flatCache[platformId]) { + flatCache[platformId] = flattenTree(CATEGORIES[platformId] || []); + } + return flatCache[platformId]; +} + +export function matchCategories(platformId, query) { + if (!query) return []; + const flat = getCategoriesFlat(platformId); + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = flat.map(cat => { + let score = 0; + if (cat.search.includes(q)) score += 100; + if (cat.name.toLowerCase() === q) score += 200; + for (const w of words) { + if (w.length < 2) continue; + if (cat.search.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} + +export function getAllPlatformIds() { + return Object.keys(CATEGORIES); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/config-store.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/config-store.mjs new file mode 100644 index 0000000..6237a2b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/config-store.mjs @@ -0,0 +1,39 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const configured = String(process.env.VENDOO_CONFIG_PATH || '').trim(); +export const CONFIG_PATH = resolve(isAbsolute(configured) ? configured : join(appRoot, configured || '.env')); + +export function readRuntimeConfig() { + return existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, 'utf8') : ''; +} + +export function setConfigValue(content, key, value) { + if (value === undefined || value === null) return content; + const clean = String(value); + if (/\r|\n/.test(clean)) throw new Error(`Mehrzeilige Konfigurationswerte sind für ${key} nicht erlaubt.`); + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped}=.*$`, 'm'); + const line = `${key}=${clean}`; + if (regex.test(content)) return content.replace(regex, line); + return `${content.trimEnd()}\n${line}\n`; +} + +export function writeRuntimeConfig(content) { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + writeFileSync(CONFIG_PATH, content, { encoding: 'utf8', mode: 0o600 }); + try { chmodSync(CONFIG_PATH, 0o600); } catch {} +} + +export function updateRuntimeConfig(values) { + let content = readRuntimeConfig(); + for (const [key, value] of Object.entries(values)) { + if (value === undefined) continue; + content = setConfigValue(content, key, value); + process.env[key] = String(value ?? ''); + } + writeRuntimeConfig(content); + return CONFIG_PATH; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/db.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/db.mjs new file mode 100644 index 0000000..4529299 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/db.mjs @@ -0,0 +1,1552 @@ +import Database from 'better-sqlite3'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { APP_ROOT, DB_PATH } from './runtime-paths.mjs'; + +const db = new Database(DB_PATH); +export { db }; + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = readFileSync(join(APP_ROOT, 'db', 'schema.sql'), 'utf-8'); +db.exec(schema); + +const MIGRATIONS = [ + ['brand', 'TEXT'], ['category', 'TEXT'], ['size', 'TEXT'], ['color', 'TEXT'], + ['condition', 'TEXT'], ['price', 'REAL'], ['status', "TEXT DEFAULT 'active'"], ['language', "TEXT DEFAULT 'de'"], + ['sku', 'TEXT'], ['deleted_at', 'TEXT'], ['storage_location', 'TEXT'], ['description_html', 'TEXT'], + ['created_by', 'INTEGER'], ['updated_by', 'INTEGER'], +]; + +for (const [col, type] of MIGRATIONS) { + try { db.exec(`ALTER TABLE listings ADD COLUMN ${col} ${type}`); } catch {} +} +try { db.exec(`CREATE TABLE IF NOT EXISTS templates (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, platform TEXT, seller_notes TEXT, language TEXT DEFAULT 'de', default_tags TEXT, created_at TEXT DEFAULT (datetime('now')))`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at)`); +} catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS flux_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + image_path TEXT NOT NULL UNIQUE, + width INTEGER NOT NULL DEFAULT 768, + height INTEGER NOT NULL DEFAULT 768, + seed INTEGER, + steps INTEGER NOT NULL DEFAULT 6, + style TEXT NOT NULL DEFAULT 'none', + edited_from INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_created ON flux_images(id DESC)`); +} catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN final_prompt TEXT`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN profile TEXT DEFAULT 'balanced'`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN duration_ms INTEGER`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN job_id TEXT`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN variant_index INTEGER`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN prompt_mode TEXT DEFAULT 'precise'`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN parameters_json TEXT`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_favorite ON flux_images(favorite, id DESC)`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_job ON flux_images(job_id, variant_index)`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS image_edit_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root_path TEXT NOT NULL, + source_path TEXT NOT NULL, + output_path TEXT NOT NULL UNIQUE, + context TEXT NOT NULL DEFAULT 'generator', + operations_json TEXT NOT NULL DEFAULT '{}', + width INTEGER, + height INTEGER, + format TEXT, + quality INTEGER, + file_size INTEGER, + user_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_edit_versions_root ON image_edit_versions(root_path, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_edit_versions_output ON image_edit_versions(output_path)`); +} catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS flux_prompt_jobs ( + id TEXT PRIMARY KEY, + prompt TEXT NOT NULL, + final_prompt TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + steps INTEGER NOT NULL, + style TEXT NOT NULL DEFAULT 'none', + profile TEXT NOT NULL DEFAULT 'balanced', + prompt_mode TEXT NOT NULL DEFAULT 'precise', + assistant INTEGER NOT NULL DEFAULT 1, + variants INTEGER NOT NULL DEFAULT 1, + seed_base INTEGER NOT NULL, + seed_lock INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + message TEXT, + error_message TEXT, + completed_variants INTEGER NOT NULL DEFAULT 0, + source_job_id TEXT, + settings_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS flux_prompt_job_variants ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + variant_index INTEGER NOT NULL, + seed INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + message TEXT, + prompt_id TEXT, + image_id INTEGER, + error_message TEXT, + duration_ms INTEGER, + cancel_requested INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES flux_prompt_jobs(id) ON DELETE CASCADE, + FOREIGN KEY(image_id) REFERENCES flux_images(id) ON DELETE SET NULL, + UNIQUE(job_id, variant_index) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_jobs_status ON flux_prompt_jobs(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_job_variants_status ON flux_prompt_job_variants(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_job_variants_job ON flux_prompt_job_variants(job_id, variant_index)`); +} catch {} + +try { db.exec(`ALTER TABLE flux_prompt_jobs ADD COLUMN archived_at TEXT`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_jobs_archive ON flux_prompt_jobs(archived_at, created_at DESC)`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE, + name TEXT NOT NULL, + description TEXT, + operations_json TEXT NOT NULL DEFAULT '{}', + filename_pattern TEXT NOT NULL DEFAULT '{name}-{profile}-{index}', + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + source_count INTEGER NOT NULL DEFAULT 0, + profile_count INTEGER NOT NULL DEFAULT 0, + total_items INTEGER NOT NULL DEFAULT 0, + completed_items INTEGER NOT NULL DEFAULT 0, + failed_items INTEGER NOT NULL DEFAULT 0, + cancelled_items INTEGER NOT NULL DEFAULT 0, + pause_requested INTEGER NOT NULL DEFAULT 0, + cancel_requested INTEGER NOT NULL DEFAULT 0, + settings_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_job_items ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + source_path TEXT NOT NULL, + profile_id INTEGER, + profile_key TEXT, + profile_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + settings_json TEXT NOT NULL DEFAULT '{}', + output_path TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES image_batch_jobs(id) ON DELETE CASCADE, + FOREIGN KEY(profile_id) REFERENCES image_batch_profiles(id) ON DELETE SET NULL + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_jobs_status ON image_batch_jobs(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_items_job ON image_batch_job_items(job_id, status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_items_output ON image_batch_job_items(output_path)`); +} catch {} + + +try { + db.exec(`CREATE TABLE IF NOT EXISTS quality_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + grade TEXT NOT NULL DEFAULT 'E', + ready INTEGER NOT NULL DEFAULT 0, + blockers INTEGER NOT NULL DEFAULT 0, + warnings INTEGER NOT NULL DEFAULT 0, + recommendations INTEGER NOT NULL DEFAULT 0, + report_json TEXT NOT NULL DEFAULT '{}', + analyzed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reports_listing ON quality_reports(listing_id, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reports_score ON quality_reports(score, analyzed_at DESC)`); +} catch {} + +const IMAGE_BATCH_BUILTINS = [ + ['ebay-main', 'eBay Hauptbild', 'Quadratisches, helles Hauptbild für eBay.', { resize: { width: 1600, height: 1600, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 18 }, '{name}-ebay-{index}'], + ['vinted-portrait', 'Vinted Hochformat', 'Hochformat 4:5 für Vinted und mobile Feeds.', { resize: { width: 1350, height: 1688, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 14 }, '{name}-vinted-{index}'], + ['kleinanzeigen', 'Kleinanzeigen', 'Großes, ausgewogenes Querformat.', { resize: { width: 1920, height: 1440, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-kleinanzeigen-{index}'], + ['etsy', 'Etsy Produktbild', 'Hochauflösendes 4:3-Produktbild.', { resize: { width: 2000, height: 1500, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 15 }, '{name}-etsy-{index}'], + ['instagram-square', 'Instagram Quadrat', 'Quadratisches Social-Media-Format.', { resize: { width: 1080, height: 1080, fit: 'cover', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-instagram-square-{index}'], + ['instagram-portrait', 'Instagram Hochformat', '4:5-Format für Feed-Beiträge.', { resize: { width: 1080, height: 1350, fit: 'cover', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-instagram-portrait-{index}'], + ['webshop', 'Webshop WebP', 'Leichtes WebP für Shops und Webseiten.', { resize: { width: 1600, height: 1600, fit: 'contain', background: '#ffffff' }, format: 'webp', quality: 84, sharpness: 10 }, '{name}-webshop-{index}'], + ['original-optimized', 'Original optimiert', 'Originalformat optimiert, ohne Vergrößerung.', { resize: { width: 2400, height: 2400, fit: 'inside', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 10 }, '{name}-optimiert-{index}'], +]; +try { + const seedBatchProfile = db.prepare(`INSERT OR IGNORE INTO image_batch_profiles + (key, name, description, operations_json, filename_pattern, is_builtin) VALUES (?, ?, ?, ?, ?, 1)`); + for (const [key, name, description, operations, pattern] of IMAGE_BATCH_BUILTINS) { + seedBatchProfile.run(key, name, description, JSON.stringify(operations), pattern); + } +} catch {} + + +try { + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + provider TEXT, + provider_model TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT + )`); + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + provider TEXT, + provider_model TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id)`); + +db.exec(`CREATE TABLE IF NOT EXISTS ai_model_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + mode TEXT NOT NULL DEFAULT 'flatlay', + preset TEXT NOT NULL DEFAULT 'mixed', + variants INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'queued', + progress INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + payload_json TEXT, + generation_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_status ON ai_model_jobs(status)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_listing ON ai_model_jobs(listing_id)`); +} catch {} + + + +try { db.exec(`ALTER TABLE ai_model_jobs ADD COLUMN progress_message TEXT`); } catch {} + +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN mode TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN mode TEXT`); } catch {} + +const defaultSettingStmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); +[ + ['ai_model_photos_enabled', '1'], + ['ai_model_photo_default_variants', '1'], + ['ai_model_photo_default_preset', 'mixed'], + ['ai_model_photo_block_underwear', '1'], + ['ai_model_photo_block_kids', '1'], + ['ai_model_photo_provider', 'auto'], + ['ai_model_photo_openrouter_free_only', '1'], + ['ai_model_photo_openrouter_model', ''], + ['ai_model_photo_openai_model', 'gpt-image-1'], + ['ai_model_photo_default_mode', 'flatlay'], + ['ai_model_photo_local_enabled', '1'], + ['ai_model_photo_local_url', 'http://127.0.0.1:8188'], + ['ai_model_photo_local_workflow_path', 'local-ai/workflows/vendoo_flux_schnell_api.json'], + ['ai_model_photo_local_install_path', 'local-ai/comfyui'], + ['ai_model_photo_local_require_token', '1'], + ['ai_model_photo_use_source_reference', '1'], + ['ai_model_photo_reference_strength', '70'], + ['ai_model_photo_local_resolution', '768'], + ['ai_model_photo_preserve_logos', '1'], + ['flux_runtime_profile', 'auto'], + ['flux_default_profile', 'balanced'], + ['flux_prompt_assistant', '1'], + ['flux_default_style', 'photorealistic'], + ['flux_default_format', '768x768'], + ['flux_default_variants', '1'], + ['flux_seed_lock', '0'], + ['flux_max_parallel_jobs', '1'], +].forEach(entry => { try { defaultSettingStmt.run(...entry); } catch {} }); + +// Slice 28: entfernt die aufgegebene lokale Try-on-Architektur vollständig aus bestehenden Datenbanken. +try { db.exec('DROP TABLE IF EXISTS vto_models'); } catch {} +try { db.prepare("DELETE FROM settings WHERE key LIKE 'ai_vto_%' OR key IN ('ai_model_photo_local_tryon_workflow_path','bfl_api_key')").run(); } catch {} +try { db.prepare("UPDATE settings SET value = 'flatlay' WHERE key = 'ai_model_photo_default_mode' AND value IN ('virtual_tryon','model')").run(); } catch {} + +// Backfill SKUs for existing listings +const noSku = db.prepare("SELECT id FROM listings WHERE sku IS NULL ORDER BY id").all(); +if (noSku.length) { + const upd = db.prepare("UPDATE listings SET sku = ? WHERE id = ?"); + for (const row of noSku) { + upd.run(`VD-${String(row.id).padStart(4, '0')}`, row.id); + } +} + +function parseJsonArray(value) { + if (Array.isArray(value)) return value; + if (value === null || value === undefined || value === '') return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return String(value).split(',').map(entry => entry.trim()).filter(Boolean); + } +} + +function parseRow(row) { + if (!row) return null; + row.tags = parseJsonArray(row.tags); + row.photos = parseJsonArray(row.photos); + return row; +} + +function generateSku() { + const row = db.prepare("SELECT MAX(CAST(SUBSTR(sku, 4) AS INTEGER)) as maxNum FROM listings WHERE sku LIKE 'VD-%'").get(); + const next = (row?.maxNum || 0) + 1; + return `VD-${String(next).padStart(4, '0')}`; +} + +export function createListing(data) { + const sku = data.sku || generateSku(); + const stmt = db.prepare(` + INSERT INTO listings (platform, ai_provider, title, description, description_html, tags, photos, seller_notes, brand, category, size, color, condition, price, status, language, sku, storage_location, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.platform, data.ai_provider, data.title, data.description, + data.description_html || null, + JSON.stringify(Array.isArray(data.tags) ? data.tags : []), + JSON.stringify(Array.isArray(data.photos) ? data.photos : []), + data.seller_notes || null, + data.brand || null, data.category || null, data.size || null, data.color || null, + data.condition || null, data.price || null, data.status || 'active', data.language || 'de', sku, + data.storage_location || null, data.created_by || null, data.updated_by || data.created_by || null + ); + return getListing(result.lastInsertRowid); +} + +export function getListing(id) { + return parseRow(db.prepare('SELECT * FROM listings WHERE id = ?').get(id)); +} + +export function getListings(query, platform, status) { + let sql = 'SELECT * FROM listings WHERE deleted_at IS NULL'; + const params = []; + if (query) { sql += ' AND (title LIKE ? OR description LIKE ? OR brand LIKE ?)'; const like = `%${query}%`; params.push(like, like, like); } + if (platform) { sql += ' AND platform = ?'; params.push(platform); } + if (status) { sql += ' AND status = ?'; params.push(status); } + sql += ' ORDER BY created_at DESC'; + return db.prepare(sql).all(...params).map(parseRow); +} + +const LISTING_UPDATE_FIELDS = new Set([ + 'platform', 'ai_provider', 'title', 'description', 'description_html', 'tags', 'photos', + 'seller_notes', 'brand', 'category', 'size', 'color', 'condition', 'price', 'status', + 'language', 'sku', 'storage_location', 'updated_by', +]); + +export function updateListing(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data || {})) { + if (!LISTING_UPDATE_FIELDS.has(key) || val === undefined) continue; + fields.push(`${key} = ?`); + values.push(['tags', 'photos'].includes(key) ? JSON.stringify(Array.isArray(val) ? val : []) : val); + } + if (!fields.length) return getListing(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE listings SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getListing(id); +} + +export function deleteListing(id) { + return softDeleteListing(id); +} + +export function softDeleteListing(id) { + return db.prepare("UPDATE listings SET deleted_at = datetime('now') WHERE id = ?").run(id); +} + +export function restoreListing(id) { + return db.prepare("UPDATE listings SET deleted_at = NULL WHERE id = ?").run(id); +} + +export function getDeletedListings() { + return db.prepare("SELECT * FROM listings WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").all().map(parseRow); +} + +export function permanentlyDeleteListing(id) { + return db.prepare('DELETE FROM listings WHERE id = ?').run(id); +} + +export function cleanupTrash(days = 30) { + return db.prepare("DELETE FROM listings WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-' || ? || ' days')").run(days); +} + +export function getTrashCount() { + const row = db.prepare("SELECT COUNT(*) as count FROM listings WHERE deleted_at IS NOT NULL").get(); + return row?.count || 0; +} + +export function duplicateListing(id) { + const original = getListing(id); + if (!original) return null; + const data = { + platform: original.platform, + ai_provider: original.ai_provider, + title: (original.title || '') + ' (Kopie)', + description: original.description, + description_html: original.description_html, + tags: original.tags, + photos: original.photos, + seller_notes: original.seller_notes, + brand: original.brand, + category: original.category, + size: original.size, + color: original.color, + condition: original.condition, + price: original.price, + status: 'active', + language: original.language, + storage_location: original.storage_location, + }; + return createListing(data); +} + + +function parseAiModelGeneration(row) { + if (!row) return null; + try { row.source_photos = JSON.parse(row.source_photos || '[]'); } catch { row.source_photos = []; } + try { row.generated_photos = JSON.parse(row.generated_photos || '[]'); } catch { row.generated_photos = []; } + return row; +} + +function parseAiModelPhoto(row) { + if (!row) return null; + return row; +} + +export function createAiModelGeneration(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_generations (listing_id, context, mode, preset, variants, source_photos, generated_photos, prompt_summary, provider, provider_model, moderation_notes, safety_status, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 3, + JSON.stringify(Array.isArray(data.source_photos) ? data.source_photos : []), + JSON.stringify(Array.isArray(data.generated_photos) ? data.generated_photos : []), + data.prompt_summary || null, + data.provider || null, + data.provider_model || null, + data.moderation_notes || null, + data.safety_status || 'pending', + data.status || 'pending' + ); + return getAiModelGeneration(result.lastInsertRowid); +} + +export function updateAiModelGeneration(id, data = {}) { + const allowed = new Set(['listing_id', 'context', 'mode', 'preset', 'variants', 'source_photos', 'generated_photos', 'prompt_summary', 'provider', 'provider_model', 'moderation_notes', 'safety_status', 'status']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(['source_photos', 'generated_photos'].includes(key) ? JSON.stringify(Array.isArray(value) ? value : []) : value); + } + if (!fields.length) return getAiModelGeneration(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_generations SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelGeneration(id); +} + +export function getAiModelGeneration(id) { + return parseAiModelGeneration(db.prepare('SELECT * FROM ai_model_generations WHERE id = ?').get(id)); +} + +export function createAiModelPhoto(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_photos (generation_id, listing_id, variant_index, mode, preset, image_path, source_photo, prompt, provider, provider_model, moderation_result, safety_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.generation_id, + data.listing_id || null, + data.variant_index || 1, + data.mode || 'model', + data.preset || 'mixed', + data.image_path, + data.source_photo || null, + data.prompt || null, + data.provider || null, + data.provider_model || null, + data.moderation_result || null, + data.safety_status || 'pending' + ); + return getAiModelPhoto(result.lastInsertRowid); +} + +export function getAiModelPhoto(id) { + return parseAiModelPhoto(db.prepare('SELECT * FROM ai_model_photos WHERE id = ?').get(id)); +} + +export function getAiModelPhotosForListing(listingId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE listing_id = ? ORDER BY created_at DESC, variant_index ASC').all(listingId).map(parseAiModelPhoto); +} + +export function getAiModelPhotosForGeneration(generationId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE generation_id = ? ORDER BY variant_index ASC').all(generationId).map(parseAiModelPhoto); +} + +export function getAiModelGenerationsForListing(listingId) { + return db.prepare("SELECT * FROM ai_model_generations WHERE listing_id = ? ORDER BY created_at DESC, id DESC").all(listingId).map(parseAiModelGeneration); +} + +export function createAiModelJob(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_jobs (listing_id, context, mode, preset, variants, status, progress, progress_message, error_message, payload_json, generation_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 1, + data.status || 'queued', + data.progress ?? 0, + data.progress_message || null, + data.error_message || null, + JSON.stringify(data.payload_json || {}), + data.generation_id || null + ); + return getAiModelJob(result.lastInsertRowid); +} + +export function getAiModelJob(id) { + const row = db.prepare('SELECT * FROM ai_model_jobs WHERE id = ?').get(id); + if (!row) return null; + try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } + return row; +} + +export function updateAiModelJob(id, data = {}) { + const allowed = new Set(['listing_id','context','mode','preset','variants','status','progress','progress_message','error_message','payload_json','generation_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(key === 'payload_json' ? JSON.stringify(value || {}) : value); + } + if (!fields.length) return getAiModelJob(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_jobs SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelJob(id); +} + +export function getPendingAiModelJobs(limit = 10) { + return db.prepare("SELECT * FROM ai_model_jobs WHERE status IN ('queued','retry') ORDER BY id ASC LIMIT ?").all(limit).map(row => { try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } return row; }); +} + +export function getSettings() { + const rows = db.prepare('SELECT key, value FROM settings').all(); + const settings = {}; + for (const row of rows) settings[row.key] = row.value; + return settings; +} + +export function updateSettings(settings) { + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + const transaction = db.transaction((entries) => { + for (const [key, value] of entries) stmt.run(key, value); + }); + transaction(Object.entries(settings)); + return getSettings(); +} + +function parseFluxImage(row) { + if (!row) return null; + try { row.parameters = JSON.parse(row.parameters_json || '{}'); } catch { row.parameters = {}; } + row.favorite = Number(row.favorite || 0) === 1; + return row; +} + +export function createFluxImage(data = {}) { + const prompt = String(data.prompt || '').trim().slice(0, 4000); + const imagePath = String(data.image_path || '').trim(); + if (!prompt || !imagePath) throw new Error('Prompt und Bildpfad sind erforderlich.'); + const existing = parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE image_path = ?').get(imagePath)); + if (existing) return existing; + const result = db.prepare(`INSERT INTO flux_images + (prompt, image_path, width, height, seed, steps, style, edited_from, final_prompt, profile, duration_ms, favorite, job_id, variant_index, prompt_mode, parameters_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + prompt, + imagePath, + Math.max(256, Number(data.width) || 768), + Math.max(256, Number(data.height) || 768), + Number.isFinite(Number(data.seed)) ? Number(data.seed) : null, + Math.max(1, Math.min(50, Number(data.steps) || 6)), + String(data.style || 'none').slice(0, 40), + Number.isFinite(Number(data.edited_from)) ? Number(data.edited_from) : null, + String(data.final_prompt || '').trim().slice(0, 5000) || null, + String(data.profile || 'balanced').slice(0, 40), + Number.isFinite(Number(data.duration_ms)) ? Math.max(0, Math.round(Number(data.duration_ms))) : null, + data.favorite === true || Number(data.favorite) === 1 ? 1 : 0, + String(data.job_id || '').trim().slice(0, 80) || null, + Number.isFinite(Number(data.variant_index)) ? Math.max(1, Math.round(Number(data.variant_index))) : null, + String(data.prompt_mode || 'precise').slice(0, 40), + JSON.stringify(data.parameters && typeof data.parameters === 'object' ? data.parameters : {}), + ); + return parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE id = ?').get(result.lastInsertRowid)); +} + +export function getFluxImages(limit = 40, { favoritesOnly = false } = {}) { + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 40)); + const rows = favoritesOnly + ? db.prepare('SELECT * FROM flux_images WHERE favorite = 1 ORDER BY id DESC LIMIT ?').all(safeLimit) + : db.prepare('SELECT * FROM flux_images ORDER BY id DESC LIMIT ?').all(safeLimit); + return rows.map(parseFluxImage); +} + +export function paginateFluxImages({ page = 1, pageSize = 5, favoritesOnly = false } = {}) { + const safePageSize = Math.max(5, Math.min(15, Number(pageSize) || 5)); + const requestedPage = Math.max(1, Number(page) || 1); + const where = favoritesOnly ? 'WHERE favorite = 1' : ''; + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM flux_images ${where}`).get()?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM flux_images ${where} ORDER BY id DESC LIMIT ? OFFSET ?`) + .all(safePageSize, (safePage - 1) * safePageSize); + return { + items: rows.map(parseFluxImage), + pagination: { page: safePage, page_size: safePageSize, total, total_pages: totalPages }, + }; +} + +export function getFluxImage(id) { + return parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE id = ?').get(Number(id))); +} + +export function setFluxImageFavorite(id, favorite = true) { + db.prepare('UPDATE flux_images SET favorite = ? WHERE id = ?').run(favorite ? 1 : 0, Number(id)); + return getFluxImage(id); +} + +export function deleteFluxImage(id) { + const image = getFluxImage(id); + if (!image) return null; + db.prepare('DELETE FROM flux_images WHERE id = ?').run(Number(id)); + return image; +} + + +function normalizeMediaPath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +export function listMediaLibraryItems({ source = 'all', search = '', favoritesOnly = false, sort = 'newest' } = {}) { + const map = new Map(); + const add = (raw = {}) => { + const imagePath = normalizeMediaPath(raw.image_path); + if (!imagePath) return; + const existing = map.get(imagePath); + const sources = new Set([...(existing?.sources || []), ...(raw.sources || []), raw.source].filter(Boolean)); + const priority = { batch: 5, flux: 4, edited: 3, ai: 2, listing: 1 }; + const shouldReplace = !existing || (priority[raw.source] || 0) > (priority[existing.source] || 0); + const base = shouldReplace ? { ...existing, ...raw } : { ...raw, ...existing }; + base.image_path = imagePath; + base.sources = [...sources]; + base.favorite = Boolean(existing?.favorite || raw.favorite); + base.created_at = [existing?.created_at, raw.created_at].filter(Boolean).sort().reverse()[0] || null; + map.set(imagePath, base); + }; + + for (const row of db.prepare('SELECT * FROM flux_images ORDER BY id DESC').all()) { + add({ id: `flux:${row.id}`, source: 'flux', sources: ['flux'], flux_id: row.id, image_path: row.image_path, + title: row.prompt || 'FLUX-Bild', prompt: row.prompt || '', final_prompt: row.final_prompt || '', favorite: Number(row.favorite || 0) === 1, + width: row.width, height: row.height, seed: row.seed, job_id: row.job_id, variant_index: row.variant_index, created_at: row.created_at }); + } + for (const row of db.prepare('SELECT * FROM image_edit_versions ORDER BY id DESC').all()) { + const isBatch = String(row.context || '') === 'batch'; + add({ id: `${isBatch ? 'batch' : 'edited'}:${row.id}`, source: isBatch ? 'batch' : 'edited', sources: [isBatch ? 'batch' : 'edited'], edit_id: row.id, image_path: row.output_path, + title: isBatch ? 'Bildproduktion' : 'Bearbeitete Bildversion', root_path: row.root_path, width: row.width, height: row.height, format: row.format, created_at: row.created_at }); + } + for (const row of db.prepare('SELECT * FROM ai_model_photos ORDER BY id DESC').all()) { + add({ id: `ai:${row.id}`, source: 'ai', sources: ['ai'], ai_id: row.id, image_path: row.image_path, + title: row.prompt || `AI-Bild ${row.variant_index || ''}`.trim(), prompt: row.prompt || '', listing_id: row.listing_id, + provider: row.provider_model || row.provider || '', preset: row.preset || '', created_at: row.created_at }); + } + for (const row of db.prepare("SELECT id, title, photos, created_at FROM listings WHERE deleted_at IS NULL ORDER BY id DESC").all()) { + for (const imagePath of parseJsonArray(row.photos)) { + add({ id: `listing:${row.id}:${normalizeMediaPath(imagePath)}`, source: 'listing', sources: ['listing'], listing_id: row.id, + image_path: imagePath, title: row.title || `Listing ${row.id}`, created_at: row.created_at }); + } + } + + const needle = String(search || '').trim().toLowerCase(); + const selectedSource = String(source || 'all').toLowerCase(); + let items = [...map.values()].filter(item => { + if (selectedSource !== 'all' && !item.sources.includes(selectedSource)) return false; + if (favoritesOnly && !item.favorite) return false; + if (!needle) return true; + return [item.title, item.prompt, item.final_prompt, item.image_path, item.provider, item.preset] + .some(value => String(value || '').toLowerCase().includes(needle)); + }); + if (sort === 'oldest') items.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || ''))); + else if (sort === 'name') items.sort((a, b) => String(a.title || a.image_path).localeCompare(String(b.title || b.image_path), 'de')); + else items.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); + return items; +} + +export function setMediaLibraryFavorite(imagePath, favorite = true) { + const normalized = normalizeMediaPath(imagePath); + const result = db.prepare('UPDATE flux_images SET favorite = ? WHERE image_path = ?').run(favorite ? 1 : 0, normalized); + return { ok: result.changes > 0, image_path: normalized, favorite: Boolean(favorite) }; +} + +export function getListingReferencedMediaPaths() { + const referenced = new Set(); + for (const row of db.prepare("SELECT photos FROM listings WHERE deleted_at IS NULL").all()) { + for (const imagePath of parseJsonArray(row.photos)) referenced.add(normalizeMediaPath(imagePath)); + } + return referenced; +} + +export function removeMediaLibraryRecords(imagePaths = []) { + const normalized = [...new Set((imagePaths || []).map(normalizeMediaPath).filter(Boolean))]; + const tx = db.transaction(() => { + for (const imagePath of normalized) { + db.prepare('DELETE FROM flux_images WHERE image_path = ?').run(imagePath); + db.prepare('DELETE FROM image_edit_versions WHERE output_path = ?').run(imagePath); + db.prepare('DELETE FROM ai_model_photos WHERE image_path = ?').run(imagePath); + } + }); + tx(); + return normalized; +} + +function parseImageEditVersion(row) { + if (!row) return null; + try { row.operations = JSON.parse(row.operations_json || '{}'); } catch { row.operations = {}; } + return row; +} + +export function getImageEditRoot(path) { + const normalized = String(path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!normalized) return null; + const row = db.prepare('SELECT root_path FROM image_edit_versions WHERE output_path = ? ORDER BY id DESC LIMIT 1').get(normalized); + return row?.root_path || null; +} + +export function createImageEditVersion(data = {}) { + const rootPath = String(data.root_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + const sourcePath = String(data.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + const outputPath = String(data.output_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!rootPath || !sourcePath || !outputPath) throw new Error('Bildversion benötigt Original-, Quell- und Zielpfad.'); + const result = db.prepare(`INSERT INTO image_edit_versions + (root_path, source_path, output_path, context, operations_json, width, height, format, quality, file_size, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + rootPath, + sourcePath, + outputPath, + String(data.context || 'generator').slice(0, 40), + JSON.stringify(data.operations && typeof data.operations === 'object' ? data.operations : {}), + Number.isFinite(Number(data.width)) ? Math.round(Number(data.width)) : null, + Number.isFinite(Number(data.height)) ? Math.round(Number(data.height)) : null, + String(data.format || '').slice(0, 20) || null, + Number.isFinite(Number(data.quality)) ? Math.round(Number(data.quality)) : null, + Number.isFinite(Number(data.file_size)) ? Math.round(Number(data.file_size)) : null, + Number.isFinite(Number(data.user_id)) ? Number(data.user_id) : null, + ); + return parseImageEditVersion(db.prepare('SELECT * FROM image_edit_versions WHERE id = ?').get(result.lastInsertRowid)); +} + +export function getImageEditVersions(path, limit = 50) { + const normalized = String(path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!normalized) return { root_path: null, versions: [] }; + const rootPath = getImageEditRoot(normalized) || normalized; + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); + const rows = db.prepare('SELECT * FROM image_edit_versions WHERE root_path = ? ORDER BY id DESC LIMIT ?').all(rootPath, safeLimit).map(parseImageEditVersion); + return { root_path: rootPath, versions: rows }; +} + + +export function createMobileUploadSession(data) { + const stmt = db.prepare(` + INSERT INTO mobile_upload_sessions (token_hash, user_id, listing_id, context, files, status, expires_at) + VALUES (?, ?, ?, ?, '[]', 'active', ?) + `); + const result = stmt.run( + data.token_hash, + data.user_id || null, + data.listing_id || null, + data.context || 'generator', + data.expires_at + ); + return getMobileUploadSessionById(result.lastInsertRowid, data.user_id || null); +} + +function parseMobileUploadSession(row) { + if (!row) return null; + try { row.files = JSON.parse(row.files || '[]'); } catch { row.files = []; } + return row; +} + +export function getMobileUploadSessionById(id, userId = null) { + const row = userId + ? db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').get(id, userId) + : db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ?').get(id); + return parseMobileUploadSession(row); +} + +export function getMobileUploadSessionByTokenHash(tokenHash) { + return parseMobileUploadSession(db.prepare('SELECT * FROM mobile_upload_sessions WHERE token_hash = ?').get(tokenHash)); +} + +export function appendMobileUploadFiles(id, filenames) { + const current = getMobileUploadSessionById(id); + if (!current) return null; + const merged = [...new Set([...(current.files || []), ...(filenames || [])])].slice(0, 10); + db.prepare("UPDATE mobile_upload_sessions SET files = ?, updated_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(merged), id); + return getMobileUploadSessionById(id); +} + +export function updateMobileUploadSession(id, patch = {}) { + const allowed = new Set(['status', 'listing_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(patch)) { + if (!allowed.has(key)) continue; + fields.push(`${key} = ?`); + values.push(value); + } + if (!fields.length) return getMobileUploadSessionById(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE mobile_upload_sessions SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getMobileUploadSessionById(id); +} + +export function deleteMobileUploadSession(id, userId = null) { + if (userId) return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').run(id, userId); + return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ?').run(id); +} + +export function cleanupMobileUploadSessions() { + return db.prepare("DELETE FROM mobile_upload_sessions WHERE expires_at < datetime('now') OR status = 'closed'").run(); +} + +// --- Seed example templates --- +const tplCount = db.prepare("SELECT COUNT(*) as c FROM templates").get(); +if (tplCount.c === 0) { + const seedTpl = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + seedTpl.run('Sneaker & Schuhe', 'vinted', + 'Originalverpackung vorhanden. Versand mit DHL, gut gepolstert. Tierfreier Nichtraucherhaushalt. Bei Fragen gerne melden!', + 'de', JSON.stringify(['#sneaker', '#schuhe', '#nike', '#adidas', '#secondhand'])); + seedTpl.run('Vintage Kleidung', 'vinted', + 'Vintage-Stück in gepflegtem Zustand. Maße siehe Beschreibung. Versand innerhalb von 1-2 Werktagen. Privatverkauf, keine Rücknahme.', + 'de', JSON.stringify(['#vintage', '#retro', '#secondhand', '#fashion', '#preloved'])); + seedTpl.run('Elektronik & Zubehör', 'ebay-de', + 'Funktioniert einwandfrei, wurde getestet. Originalzubehör dabei, sofern nicht anders angegeben. Versand als versichertes Paket.', + 'de', JSON.stringify(['Elektronik', 'Technik', 'Zubehör', 'gebraucht', 'getestet'])); +} + +// --- Templates --- +export function createTemplate(data) { + const stmt = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + const result = stmt.run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || [])); + return getTemplate(result.lastInsertRowid); +} + +export function getTemplate(id) { + const row = db.prepare('SELECT * FROM templates WHERE id = ?').get(id); + if (!row) return null; + row.default_tags = JSON.parse(row.default_tags || '[]'); + return row; +} + +export function getTemplates() { + return db.prepare('SELECT * FROM templates ORDER BY name').all().map(r => { + r.default_tags = JSON.parse(r.default_tags || '[]'); + return r; + }); +} + +export function updateTemplate(id, data) { + db.prepare('UPDATE templates SET name = ?, platform = ?, seller_notes = ?, language = ?, default_tags = ? WHERE id = ?') + .run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || []), id); + return getTemplate(id); +} + +export function deleteTemplate(id) { + return db.prepare('DELETE FROM templates WHERE id = ?').run(id); +} + +// --- Publish Log --- +try { db.exec(`CREATE TABLE IF NOT EXISTS publish_log (id INTEGER PRIMARY KEY AUTOINCREMENT, listing_id INTEGER NOT NULL, platform TEXT NOT NULL, status TEXT DEFAULT 'pending', external_id TEXT, external_url TEXT, published_at TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE)`); } catch {} + +export function createPublishLog(listingId, platform, status, externalId, externalUrl) { + const stmt = db.prepare('INSERT INTO publish_log (listing_id, platform, status, external_id, external_url, published_at) VALUES (?, ?, ?, ?, ?, datetime(?))'); + stmt.run(listingId, platform, status || 'pending', externalId || null, externalUrl || null, status === 'published' ? 'now' : null); +} + +export function updatePublishLog(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + if (data.status === 'published') { fields.push("published_at = datetime('now')"); } + values.push(id); + db.prepare(`UPDATE publish_log SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function getPublishLogs(listingId) { + return db.prepare('SELECT * FROM publish_log WHERE listing_id = ? ORDER BY created_at DESC').all(listingId); +} + +export function getPublishStatus(listingIds) { + if (!listingIds.length) return {}; + const placeholders = listingIds.map(() => '?').join(','); + const rows = db.prepare(`SELECT listing_id, platform, status FROM publish_log WHERE listing_id IN (${placeholders})`).all(...listingIds); + const result = {}; + for (const r of rows) { + if (!result[r.listing_id]) result[r.listing_id] = {}; + result[r.listing_id][r.platform] = r.status; + } + return result; +} + +// --- eBay Queue --- +try { db.exec(`CREATE TABLE IF NOT EXISTS ebay_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + status TEXT DEFAULT 'pending', + ebay_item_id TEXT, + ebay_url TEXT, + error TEXT, + retries INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function addToEbayQueue(listingIds) { + const stmt = db.prepare('INSERT OR IGNORE INTO ebay_queue (listing_id) VALUES (?)'); + const existing = db.prepare('SELECT listing_id FROM ebay_queue WHERE listing_id = ? AND status IN (?, ?)'); + const added = []; + for (const id of listingIds) { + const ex = existing.get(id, 'pending', 'uploading'); + if (!ex) { + stmt.run(id); + added.push(id); + } + } + return added; +} + +export function getEbayQueue(status) { + let sql = 'SELECT q.*, l.title, l.sku, l.platform FROM ebay_queue q JOIN listings l ON q.listing_id = l.id'; + if (status) { sql += ' WHERE q.status = ?'; return db.prepare(sql + ' ORDER BY q.created_at').all(status); } + return db.prepare(sql + ' ORDER BY q.created_at DESC').all(); +} + +export function updateEbayQueue(id, data) { + const fields = ["updated_at = datetime('now')"]; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + values.push(id); + db.prepare(`UPDATE ebay_queue SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeFromEbayQueue(id) { + db.prepare('DELETE FROM ebay_queue WHERE id = ?').run(id); +} + +export function clearEbayQueue(status) { + if (status) db.prepare('DELETE FROM ebay_queue WHERE status = ?').run(status); + else db.prepare('DELETE FROM ebay_queue').run(); +} + +// --- Scheduled Publishes --- +try { db.exec(`CREATE TABLE IF NOT EXISTS scheduled_publish ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + scheduled_at TEXT NOT NULL, + status TEXT DEFAULT 'scheduled', + error TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function schedulePublish(listingId, platform, scheduledAt) { + const existing = db.prepare('SELECT id FROM scheduled_publish WHERE listing_id = ? AND platform = ? AND status = ?').get(listingId, platform, 'scheduled'); + if (existing) { + db.prepare('UPDATE scheduled_publish SET scheduled_at = ? WHERE id = ?').run(scheduledAt, existing.id); + return existing.id; + } + const r = db.prepare('INSERT INTO scheduled_publish (listing_id, platform, scheduled_at) VALUES (?, ?, ?)').run(listingId, platform, scheduledAt); + return r.lastInsertRowid; +} + +export function getScheduledPublishes(status) { + let sql = 'SELECT s.*, l.title, l.sku, l.price FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id'; + if (status) { sql += ' WHERE s.status = ?'; return db.prepare(sql + ' ORDER BY s.scheduled_at').all(status); } + return db.prepare(sql + ' ORDER BY s.scheduled_at').all(); +} + +export function getDueScheduled() { + return db.prepare("SELECT s.*, l.title, l.sku, l.platform as listing_platform FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id WHERE s.status = 'scheduled' AND s.scheduled_at <= datetime('now') ORDER BY s.scheduled_at").all(); +} + +export function updateScheduledPublish(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + values.push(id); + db.prepare(`UPDATE scheduled_publish SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeScheduledPublish(id) { + db.prepare('DELETE FROM scheduled_publish WHERE id = ?').run(id); +} + +// --- Inventory / Storage --- +export function getStorageLocations() { + return db.prepare(` + SELECT storage_location, COUNT(*) as count, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'sold' THEN 1 ELSE 0 END) as sold, + SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END) as reserved, + COALESCE(SUM(CASE WHEN status = 'active' THEN price ELSE 0 END), 0) as total_value + FROM listings + WHERE deleted_at IS NULL AND storage_location IS NOT NULL AND storage_location != '' + GROUP BY storage_location ORDER BY storage_location + `).all(); +} + +export function getListingsByLocation(location) { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND storage_location = ? ORDER BY created_at DESC" + ).all(location).map(parseRow); +} + +export function getUnassignedListings() { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND (storage_location IS NULL OR storage_location = '') ORDER BY created_at DESC" + ).all().map(parseRow); +} + +export function bulkUpdateLocation(ids, location) { + const stmt = db.prepare("UPDATE listings SET storage_location = ?, updated_at = datetime('now') WHERE id = ?"); + const tx = db.transaction(() => { for (const id of ids) stmt.run(location, id); }); + tx(); + return ids.length; +} + +// --- CSV Export --- +export function getListingsForExport(platform, status) { + return getListings(null, platform, status); +} + +// --- Studio Flow Dashboard --- +function listingCompleteness(listing) { + const checks = [ + [listing.title, 14], + [listing.description, 16], + [Array.isArray(listing.photos) && listing.photos.length > 0, 18], + [listing.brand, 8], + [listing.category, 12], + [listing.condition, 10], + [Number(listing.price) > 0, 10], + [listing.size, 4], + [listing.color, 4], + [Array.isArray(listing.tags) && listing.tags.length > 0, 4], + ]; + return checks.reduce((score, [value, weight]) => score + (value ? weight : 0), 0); +} + +function sqliteDateToIso(value) { + if (!value) return null; + if (value.includes('T')) return value; + return `${value.replace(' ', 'T')}Z`; +} + +export function getDashboardWorkflow(limitPerStage = 3) { + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare("SELECT * FROM scheduled_publish WHERE status = 'scheduled' ORDER BY scheduled_at").all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + + const latestPublish = new Map(); + for (const row of publishRows) { + if (!latestPublish.has(row.listing_id)) latestPublish.set(row.listing_id, row); + } + const activeSchedule = new Map(scheduleRows.map(row => [row.listing_id, row])); + const latestQueue = new Map(); + for (const row of queueRows) { + if (!latestQueue.has(row.listing_id)) latestQueue.set(row.listing_id, row); + } + + const stages = { + captured: [], + ai_review: [], + ready: [], + scheduled: [], + published: [], + sold: [], + }; + + const itemById = new Map(); + for (const listing of listings) { + const completeness = listingCompleteness(listing); + const publish = latestPublish.get(listing.id) || null; + const schedule = activeSchedule.get(listing.id) || null; + const queue = latestQueue.get(listing.id) || null; + + let stage = 'ready'; + if (listing.status === 'sold') stage = 'sold'; + else if (schedule) stage = 'scheduled'; + else if (publish?.status === 'published' || queue?.status === 'published') stage = 'published'; + else if (completeness < 55) stage = 'captured'; + else if (completeness < 85) stage = 'ai_review'; + + const item = { + id: listing.id, + title: listing.title || 'Unbenanntes Listing', + sku: listing.sku || `VD-${String(listing.id).padStart(4, '0')}`, + platform: listing.platform, + aiProvider: listing.ai_provider, + photos: listing.photos || [], + price: listing.price, + status: listing.status, + storageLocation: listing.storage_location, + brand: listing.brand, + category: listing.category, + size: listing.size, + color: listing.color, + condition: listing.condition, + completeness, + stage, + scheduledAt: schedule?.scheduled_at || null, + publishStatus: publish?.status || queue?.status || null, + queueStatus: queue?.status || null, + createdAt: listing.created_at, + updatedAt: listing.updated_at, + }; + stages[stage].push(item); + itemById.set(listing.id, item); + } + + const attention = []; + const addAttention = (listingId, type, message, action, severity = 'warning') => { + const item = itemById.get(listingId); + if (!item) return; + attention.push({ + id: `${listingId}:${type}`, + listingId, + type, + message, + action, + severity, + title: item.title, + sku: item.sku, + photo: item.photos?.[0] || null, + createdAt: item.updatedAt || item.createdAt, + }); + }; + + for (const row of queueRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', row.error || 'eBay-Veröffentlichung fehlgeschlagen', 'Erneut versuchen', 'danger'); + } + for (const row of publishRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', 'Veröffentlichung fehlgeschlagen', 'Prüfen', 'danger'); + } + for (const item of itemById.values()) { + if (!Number(item.price)) addAttention(item.id, 'missing_price', 'Preis fehlt', 'Preis setzen'); + if (!item.storageLocation && item.status !== 'sold') addAttention(item.id, 'missing_location', 'Lagerort fehlt', 'Lagerort setzen'); + if (item.completeness < 85) addAttention(item.id, 'low_confidence', `Listing-Qualität ${item.completeness}%`, 'Prüfen'); + } + + const attentionPriority = { danger: 0, warning: 1, info: 2 }; + attention.sort((a, b) => { + const severity = (attentionPriority[a.severity] ?? 9) - (attentionPriority[b.severity] ?? 9); + if (severity) return severity; + return String(b.createdAt || '').localeCompare(String(a.createdAt || '')); + }); + + const activity = []; + for (const listing of listings.slice(0, 12)) { + activity.push({ + type: listing.status === 'sold' ? 'sold' : 'created', + listingId: listing.id, + text: listing.status === 'sold' + ? `${listing.title || listing.sku} wurde als verkauft markiert` + : `${listing.title || listing.sku} wurde erfasst`, + at: sqliteDateToIso(listing.updated_at || listing.created_at), + }); + } + for (const row of publishRows.slice(0, 12)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + const verbs = { published: 'veröffentlicht', failed: 'konnte nicht veröffentlicht werden', copied: 'für Smart Copy vorbereitet', pending: 'zur Veröffentlichung vorgemerkt' }; + activity.push({ + type: row.status || 'published', + listingId: row.listing_id, + text: `${item.title} wurde auf ${row.platform} ${verbs[row.status] || row.status}`, + at: sqliteDateToIso(row.published_at || row.created_at), + }); + } + for (const row of scheduleRows.slice(0, 8)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + activity.push({ + type: 'scheduled', + listingId: row.listing_id, + text: `${item.title} wurde für ${row.platform} geplant`, + at: sqliteDateToIso(row.created_at), + }); + } + activity.sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))); + + const orderedStages = {}; + for (const [key, items] of Object.entries(stages)) { + orderedStages[key] = { + count: items.length, + items: items.slice(0, Math.max(1, Number(limitPerStage) || 3)), + }; + } + + return { + stages: orderedStages, + attention: attention.slice(0, 6), + attentionCount: attention.length, + activity: activity.slice(0, 8), + totals: { + listings: listings.length, + active: listings.filter(item => item.status === 'active').length, + sold: listings.filter(item => item.status === 'sold').length, + inventoryValue: listings.reduce((sum, item) => sum + (item.status !== 'sold' ? Number(item.price) || 0 : 0), 0), + }, + }; +} + +export function getDashboardAnalytics(days = 30) { + const periodDays = Math.min(Math.max(Number(days) || 30, 7), 365); + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare('SELECT * FROM scheduled_publish ORDER BY created_at DESC').all(); + const locations = getStorageLocations(); + + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - (periodDays - 1)); + const weekStart = new Date(now); + weekStart.setDate(weekStart.getDate() - 7); + const todayKey = now.toISOString().slice(0, 10); + + const soldListings = listings.filter(item => item.status === 'sold'); + const dateOf = item => { + const raw = item.updated_at || item.created_at; + if (!raw) return null; + const parsed = new Date(String(raw).replace(' ', 'T') + (String(raw).includes('Z') ? '' : 'Z')); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const timelineMap = new Map(); + for (let index = 0; index < periodDays; index++) { + const date = new Date(start); + date.setDate(start.getDate() + index); + const key = date.toISOString().slice(0, 10); + timelineMap.set(key, { date: key, revenue: 0, sales: 0 }); + } + + const platformRevenue = new Map(); + const categoryRevenue = new Map(); + for (const listing of soldListings) { + const date = dateOf(listing); + const price = Number(listing.price) || 0; + if (date) { + const key = date.toISOString().slice(0, 10); + const bucket = timelineMap.get(key); + if (bucket) { + bucket.revenue += price; + bucket.sales += 1; + } + } + const platform = listing.platform || 'Sonstige'; + platformRevenue.set(platform, (platformRevenue.get(platform) || 0) + price); + const category = listing.category ? String(listing.category).split('>').pop().trim() : 'Sonstiges'; + categoryRevenue.set(category, (categoryRevenue.get(category) || 0) + price); + } + + const successes = publishRows.filter(row => row.status === 'published').length; + const failures = publishRows.filter(row => row.status === 'failed').length + queueRows.filter(row => row.status === 'failed').length; + const publishAttempts = successes + failures; + const quality = listings.length + ? Math.round(listings.reduce((sum, item) => sum + listingCompleteness(item), 0) / listings.length) + : 0; + + const workflow = getDashboardWorkflow(6); + const recentActivity = workflow.activity.slice(0, 6); + const notifications = workflow.attention.slice(0, 6); + const mobileUploads = db.prepare("SELECT COUNT(*) AS count FROM mobile_upload_sessions WHERE created_at >= datetime('now', '-24 hours')").get()?.count || 0; + + const topSellers = [...soldListings] + .sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)) + .slice(0, 5) + .map(item => ({ + id: item.id, + title: item.title, + sku: item.sku, + platform: item.platform, + revenue: Number(item.price) || 0, + sales: 1, + status: item.status, + photo: item.photos?.[0] || null, + size: item.size, + color: item.color, + })); + + return { + periodDays, + generatedAt: new Date().toISOString(), + kpis: { + revenueToday: soldListings.filter(item => String(item.updated_at || item.created_at || '').startsWith(todayKey)).reduce((sum, item) => sum + (Number(item.price) || 0), 0), + sales7Days: soldListings.filter(item => { const date = dateOf(item); return date && date >= weekStart; }).length, + activeListings: listings.filter(item => !['sold', 'draft'].includes(item.status)).length, + drafts: listings.filter(item => item.status === 'draft').length, + inventoryValue: listings.filter(item => item.status !== 'sold').reduce((sum, item) => sum + (Number(item.price) || 0), 0), + publishSuccessRate: publishAttempts ? Math.round((successes / publishAttempts) * 1000) / 10 : 100, + quality, + }, + timeline: [...timelineMap.values()], + platformRevenue: [...platformRevenue.entries()].map(([platform, revenue]) => ({ platform, revenue })).sort((a, b) => b.revenue - a.revenue), + categoryRevenue: [...categoryRevenue.entries()].map(([category, revenue]) => ({ category, revenue })).sort((a, b) => b.revenue - a.revenue).slice(0, 6), + focus: { + ready: workflow.stages?.ready?.count || 0, + priceChecks: workflow.attention.filter(item => item.type === 'missing_price').length, + failedPublishes: failures, + mobileUploads, + activeLocations: locations.filter(item => Number(item.count) > 0).length, + }, + topSellers, + activity: recentActivity, + notifications, + system: { + healthy: true, + queuePending: queueRows.filter(item => item.status === 'pending').length, + scheduled: scheduleRows.filter(item => item.status === 'scheduled').length, + }, + }; +} + +// --- Publishing Hardening 1.29.0 --- +try { + db.exec(`CREATE TABLE IF NOT EXISTS publishing_jobs ( + id TEXT PRIMARY KEY, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'api', + action TEXT NOT NULL DEFAULT 'publish', + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + idempotency_key TEXT NOT NULL UNIQUE, + external_id TEXT, + external_url TEXT, + offer_id TEXT, + retries INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_attempt_at TEXT, + claimed_by TEXT, + claimed_at TEXT, + error_code TEXT, + error_message TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE TABLE IF NOT EXISTS publishing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES publishing_jobs(id) ON DELETE SET NULL, + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_jobs_status ON publishing_jobs(status, next_attempt_at, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_jobs_listing ON publishing_jobs(listing_id, platform, created_at DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_events_job ON publishing_events(job_id, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_events_listing ON publishing_events(listing_id, id DESC)`); +} catch {} + +for (const [column, type] of [ + ['offer_id', 'TEXT'], ['sync_status', 'TEXT'], ['last_synced_at', 'TEXT'], ['ended_at', 'TEXT'], ['updated_at', 'TEXT'] +]) { + try { db.exec(`ALTER TABLE publish_log ADD COLUMN ${column} ${type}`); } catch {} +} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_publish_log_external ON publish_log(platform, external_id)`); } catch {} + +// --- Operations Center 1.30.0 --- +try { + db.exec(`CREATE TABLE IF NOT EXISTS operation_backups ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'manual', + label TEXT, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + status TEXT NOT NULL DEFAULT 'created', + verified_at TEXT, + options_json TEXT NOT NULL DEFAULT '{}', + manifest_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_operation_backups_created ON operation_backups(created_at DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_operation_backups_kind ON operation_backups(kind, created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS operation_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS update_operations ( + id TEXT PRIMARY KEY, + version_from TEXT NOT NULL, + version_to TEXT NOT NULL, + source_name TEXT, + archive_path TEXT NOT NULL, + sha256 TEXT, + status TEXT NOT NULL DEFAULT 'staged', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + applied_at TEXT, + error_message TEXT + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_update_operations_created ON update_operations(created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS system_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + migration_key TEXT NOT NULL UNIQUE, + app_version TEXT NOT NULL, + description TEXT, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS extension_clients ( + client_id TEXT PRIMARY KEY, + browser TEXT, + version TEXT, + platform TEXT, + server_url TEXT, + user_agent TEXT, + capabilities_json TEXT NOT NULL DEFAULT '{}', + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_extension_clients_seen ON extension_clients(last_seen DESC)`); + + db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) + VALUES ('operations-center-1.30.0', '1.30.0', 'Backup, Restore, Update Center und Extension-Diagnose')`).run(); +} catch {} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ebay-api.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ebay-api.mjs new file mode 100644 index 0000000..f14d2fd --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/ebay-api.mjs @@ -0,0 +1,304 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const EBAY_AUTH_SANDBOX = 'https://auth.sandbox.ebay.com/oauth2/authorize'; +const EBAY_AUTH_PROD = 'https://auth.ebay.com/oauth2/authorize'; +const EBAY_TOKEN_SANDBOX = 'https://api.sandbox.ebay.com/identity/v1/oauth2/token'; +const EBAY_TOKEN_PROD = 'https://api.ebay.com/identity/v1/oauth2/token'; +const EBAY_API_SANDBOX = 'https://api.sandbox.ebay.com'; +const EBAY_API_PROD = 'https://api.ebay.com'; + +function isSandbox() { + return process.env.EBAY_SANDBOX === 'true'; +} + +function authUrl() { return isSandbox() ? EBAY_AUTH_SANDBOX : EBAY_AUTH_PROD; } +function tokenUrl() { return isSandbox() ? EBAY_TOKEN_SANDBOX : EBAY_TOKEN_PROD; } +function apiBase() { return isSandbox() ? EBAY_API_SANDBOX : EBAY_API_PROD; } + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function getBasicAuth() { + const clientId = process.env.EBAY_CLIENT_ID; + const clientSecret = process.env.EBAY_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new Error('EBAY_CLIENT_ID oder EBAY_CLIENT_SECRET nicht gesetzt'); + return Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); +} + +const SCOPES = [ + 'https://api.ebay.com/oauth/api_scope', + 'https://api.ebay.com/oauth/api_scope/sell.inventory', + 'https://api.ebay.com/oauth/api_scope/sell.account', + 'https://api.ebay.com/oauth/api_scope/sell.fulfillment', + 'https://api.ebay.com/oauth/api_scope/sell.marketing', +].join(' '); + +let oauthState = null; + +export function getOAuthUrl(redirectUri) { + if (!process.env.EBAY_CLIENT_ID) throw new Error('EBAY_CLIENT_ID nicht gesetzt'); + oauthState = Math.random().toString(36).substring(2); + + const params = new URLSearchParams({ + client_id: process.env.EBAY_CLIENT_ID, + response_type: 'code', + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + scope: SCOPES, + state: oauthState, + }); + return `${authUrl()}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + if (oauthState && state !== oauthState) throw new Error('Ungültiger OAuth-State'); + oauthState = null; + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const refreshTkn = process.env.EBAY_REFRESH_TOKEN; + if (!refreshTkn) throw new Error('Kein eBay Refresh-Token'); + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshTkn, + scope: SCOPES, + }), + }); + + if (!res.ok) throw new Error('eBay Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + if (tokens.refresh_token) setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function ebayFetch(path, opts = {}) { + let token = process.env.EBAY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit eBay verbunden'); + + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Content-Language': 'de-DE', + 'X-EBAY-C-MARKETPLACE-ID': 'EBAY_DE', + ...opts.headers, + }; + + let res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + } + + if (res.status === 204) return null; + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay API (${res.status}): ${err}`); + } + + const ct = res.headers.get('content-type') || ''; + return ct.includes('json') ? res.json() : res.text(); +} + +function mapCondition(condition) { + const map = { + 'neu': 'NEW', 'new': 'NEW', + 'wie neu': 'LIKE_NEW', 'like new': 'LIKE_NEW', + 'sehr gut': 'VERY_GOOD', 'very good': 'VERY_GOOD', + 'gut': 'GOOD', 'good': 'GOOD', + 'akzeptabel': 'ACCEPTABLE', 'acceptable': 'ACCEPTABLE', + 'defekt': 'FOR_PARTS_OR_NOT_WORKING', + }; + return map[(condition || '').toLowerCase()] || 'GOOD'; +} + +export async function createInventoryItem(listing, sku) { + const images = (listing.photos || []).map(p => `http://localhost:${process.env.PORT || 8124}/uploads/${p}`); + + const item = { + availability: { + shipToLocationAvailability: { quantity: 1 }, + }, + condition: mapCondition(listing.condition), + product: { + title: listing.title, + description: listing.description || '', + aspects: {}, + imageUrls: images, + }, + }; + + if (listing.brand) item.product.aspects['Marke'] = [listing.brand]; + if (listing.color) item.product.aspects['Farbe'] = [listing.color]; + if (listing.size) item.product.aspects['Größe'] = [listing.size]; + + await ebayFetch(`/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, { + method: 'PUT', + body: JSON.stringify(item), + }); + + return sku; +} + +export async function createOffer(listing, sku) { + const price = listing.price || listing.suggested_price || 1; + + const offer = { + sku, + marketplaceId: 'EBAY_DE', + format: 'FIXED_PRICE', + listingDescription: listing.description || '', + availableQuantity: 1, + pricingSummary: { + price: { value: String(price), currency: 'EUR' }, + }, + listingPolicies: {}, + merchantLocationKey: process.env.EBAY_LOCATION_KEY || undefined, + }; + + const categoryId = listing.ebay_category_id || process.env.EBAY_DEFAULT_CATEGORY || '175673'; + offer.categoryId = categoryId; + + const fulfillmentId = process.env.EBAY_FULFILLMENT_POLICY_ID; + const paymentId = process.env.EBAY_PAYMENT_POLICY_ID; + const returnId = process.env.EBAY_RETURN_POLICY_ID; + + if (fulfillmentId) offer.listingPolicies.fulfillmentPolicyId = fulfillmentId; + if (paymentId) offer.listingPolicies.paymentPolicyId = paymentId; + if (returnId) offer.listingPolicies.returnPolicyId = returnId; + + const result = await ebayFetch('/sell/inventory/v1/offer', { + method: 'POST', + body: JSON.stringify(offer), + }); + + return result; +} + +export async function publishOffer(offerId) { + const result = await ebayFetch(`/sell/inventory/v1/offer/${offerId}/publish`, { + method: 'POST', + }); + return result; +} + +export async function createAndPublish(listing, sku) { + await createInventoryItem(listing, sku); + const offer = await createOffer(listing, sku); + const offerId = offer.offerId; + const published = await publishOffer(offerId); + return { + offerId, + listingId: published.listingId, + url: `https://www.ebay.de/itm/${published.listingId}`, + }; +} + +export async function getFulfillmentPolicies() { + return ebayFetch('/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_DE'); +} + +export async function getPaymentPolicies() { + return ebayFetch('/sell/account/v1/payment_policy?marketplace_id=EBAY_DE'); +} + +export async function getReturnPolicies() { + return ebayFetch('/sell/account/v1/return_policy?marketplace_id=EBAY_DE'); +} + +export async function getPolicies() { + const [fulfillment, payment, returns] = await Promise.all([ + getFulfillmentPolicies().catch(() => ({ fulfillmentPolicies: [] })), + getPaymentPolicies().catch(() => ({ paymentPolicies: [] })), + getReturnPolicies().catch(() => ({ returnPolicies: [] })), + ]); + return { + fulfillment: fulfillment.fulfillmentPolicies || [], + payment: payment.paymentPolicies || [], + returns: returns.returnPolicies || [], + }; +} + +export function isConnected() { + return !!(process.env.EBAY_ACCESS_TOKEN && process.env.EBAY_CLIENT_ID); +} + +export function hasCredentials() { + return !!(process.env.EBAY_CLIENT_ID && process.env.EBAY_CLIENT_SECRET); +} + +export async function getOffer(offerId) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + return ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}`); +} + +export async function updateOffer(listing, offerId, sku) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + await createInventoryItem(listing, sku); + const price = listing.price || listing.suggested_price || 1; + const body = { + sku, + marketplaceId: 'EBAY_DE', + format: 'FIXED_PRICE', + listingDescription: listing.description || '', + availableQuantity: 1, + pricingSummary: { price: { value: String(price), currency: 'EUR' } }, + listingPolicies: {}, + categoryId: listing.ebay_category_id || process.env.EBAY_DEFAULT_CATEGORY || '175673', + merchantLocationKey: process.env.EBAY_LOCATION_KEY || undefined, + }; + if (process.env.EBAY_FULFILLMENT_POLICY_ID) body.listingPolicies.fulfillmentPolicyId = process.env.EBAY_FULFILLMENT_POLICY_ID; + if (process.env.EBAY_PAYMENT_POLICY_ID) body.listingPolicies.paymentPolicyId = process.env.EBAY_PAYMENT_POLICY_ID; + if (process.env.EBAY_RETURN_POLICY_ID) body.listingPolicies.returnPolicyId = process.env.EBAY_RETURN_POLICY_ID; + await ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}`, { method: 'PUT', body: JSON.stringify(body) }); + return getOffer(offerId); +} + +export async function withdrawOffer(offerId) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + return ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`, { method: 'POST' }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/etsy-api.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/etsy-api.mjs new file mode 100644 index 0000000..5b83247 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/etsy-api.mjs @@ -0,0 +1,189 @@ +import { randomBytes, createHash } from 'crypto'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const ETSY_BASE = 'https://api.etsy.com/v3'; +const ETSY_AUTH = 'https://www.etsy.com/oauth/connect'; +const ETSY_TOKEN = 'https://api.etsy.com/v3/public/oauth/token'; + +let pkceStore = {}; + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function generatePKCE() { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +export function getAuthUrl(redirectUri) { + const apiKey = process.env.ETSY_API_KEY; + if (!apiKey) throw new Error('ETSY_API_KEY nicht gesetzt'); + + const { verifier, challenge } = generatePKCE(); + const state = randomBytes(16).toString('hex'); + pkceStore[state] = verifier; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: apiKey, + redirect_uri: redirectUri, + scope: 'listings_w listings_r shops_r images_w', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + return `${ETSY_AUTH}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + const verifier = pkceStore[state]; + if (!verifier) throw new Error('Ungültiger OAuth-State'); + delete pkceStore[state]; + + const apiKey = process.env.ETSY_API_KEY; + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: apiKey, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const apiKey = process.env.ETSY_API_KEY; + const refreshToken = process.env.ETSY_REFRESH_TOKEN; + if (!refreshToken) throw new Error('Kein Etsy Refresh-Token'); + + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: apiKey, + refresh_token: refreshToken, + }), + }); + + if (!res.ok) throw new Error('Etsy Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function etsyFetch(path, opts = {}) { + let token = process.env.ETSY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit Etsy verbunden'); + + const apiKey = process.env.ETSY_API_KEY; + const headers = { 'x-api-key': apiKey, Authorization: `Bearer ${token}`, ...opts.headers }; + let res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + } + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy API Fehler (${res.status}): ${err}`); + } + return res.json(); +} + +export async function getShopId() { + const data = await etsyFetch('/application/users/me'); + const userId = data.user_id; + const shop = await etsyFetch(`/application/users/${userId}/shops`); + if (!shop.results?.length) throw new Error('Kein Etsy-Shop gefunden'); + return shop.results[0].shop_id; +} + +export async function createEtsyListing(listing, shopId) { + const tags = Array.isArray(listing.tags) ? listing.tags.slice(0, 13) : []; + const price = listing.price || listing.suggested_price || 10; + + const body = { + quantity: 1, + title: listing.title, + description: listing.description, + price: price, + who_made: 'someone_else', + when_made: '2020_2025', + taxonomy_id: 1, + tags, + type: 'physical', + shipping_profile_id: null, + state: 'draft', + }; + + const result = await etsyFetch(`/application/shops/${shopId}/listings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return result; +} + +export async function uploadEtsyImage(shopId, listingId, imageBuffer, filename) { + const formData = new FormData(); + formData.append('image', new Blob([imageBuffer]), filename); + + const token = process.env.ETSY_ACCESS_TOKEN; + const apiKey = process.env.ETSY_API_KEY; + + const res = await fetch(`${ETSY_BASE}/application/shops/${shopId}/listings/${listingId}/images`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Bild-Upload fehlgeschlagen: ${err}`); + } + return res.json(); +} + +export function isConnected() { + return !!(process.env.ETSY_ACCESS_TOKEN && process.env.ETSY_API_KEY); +} + +export async function getEtsyListing(listingId) { + if (!listingId) throw new Error('Etsy Listing-ID fehlt'); + return etsyFetch(`/application/listings/${encodeURIComponent(listingId)}`); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/fees.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/fees.mjs new file mode 100644 index 0000000..0350747 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/fees.mjs @@ -0,0 +1,82 @@ +const roundMoney = value => Math.round((Number(value) || 0) * 100) / 100; + +const PLATFORM_FEES = { + vinted: { + name: 'Vinted', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsgebühr für Verkäufer. Den Käuferschutz zahlt der Käufer zusätzlich.', + note: 'Optionale Hervorhebungen oder andere Zusatzdienste sind nicht enthalten.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + 'ebay-ka': { + name: 'Kleinanzeigen', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsprovision beim normalen Privatverkauf.', + note: 'Kosten können bei Zusatzoptionen, überschrittenen Anzeigenlimits oder bestimmten Fahrzeug-/Spezialkategorien entstehen.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + 'ebay-de': { + name: 'eBay.de', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsprovision für private Verkäufer bei Verkäufen innerhalb Deutschlands.', + note: 'Gebühren für Zusatzoptionen und gegebenenfalls internationale Verkäufe sind nicht enthalten.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + etsy: { + name: 'Etsy', + calculate(price) { + const gross = roundMoney(price); + const transaction = gross * 0.065; + const processing = gross * 0.04 + 0.30; + const fees = roundMoney(transaction + processing); + return { + gross, + fees, + net: roundMoney(gross - fees), + breakdown: '6,5 % Transaktionsgebühr + 4 % + 0,30 € Etsy Payments.', + note: 'Zusätzlich 0,20 USD Einstellgebühr je Artikel/Erneuerung; mögliche USt., Währungsumrechnung und Werbegebühren sind nicht eingerechnet.', + seller_type: 'shop', + verified_at: '2026-07-08', + }; + }, + }, +}; + +export function calculateFees(platform, price) { + const calc = PLATFORM_FEES[platform]; + if (!calc) { + const gross = roundMoney(price); + return { gross, fees: 0, net: gross, breakdown: 'Unbekannte Plattform', note: '', seller_type: 'unknown' }; + } + return calc.calculate(price); +} + +export function getAllFees(price) { + const result = {}; + for (const [id, calc] of Object.entries(PLATFORM_FEES)) result[id] = calc.calculate(price); + return result; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/html-templates.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/html-templates.mjs new file mode 100644 index 0000000..5807ccb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/html-templates.mjs @@ -0,0 +1,163 @@ +const TEMPLATES = { + vinted: { + name: 'Vinted Classic', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags.join(' ') : listing.tags || ''; + return `
                          +

                          ${richDescription(listing)}

                          + ${listing.brand || listing.size || listing.color ? ` + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                          Marke${esc(listing.brand)}
                          Kategorie${esc(listing.category)}
                          Größe${esc(listing.size)}
                          Farbe${esc(listing.color)}
                          Zustand${esc(listing.condition)}
                          ` : ''} + ${tags ? `

                          ${esc(tags)}

                          ` : ''} +
                          `; + }, + }, + + 'ebay-ka': { + name: 'Kleinanzeigen Modern', + wrap(listing) { + return `
                          +

                          ${richDescription(listing)}

                          + ${listing.brand || listing.size ? `
                          +

                          Artikeldetails

                          +
                          + ${listing.brand ? `
                          Marke: ${esc(listing.brand)}
                          ` : ''} + ${listing.category ? `
                          Kategorie: ${esc(listing.category)}
                          ` : ''} + ${listing.size ? `
                          Größe: ${esc(listing.size)}
                          ` : ''} + ${listing.color ? `
                          Farbe: ${esc(listing.color)}
                          ` : ''} + ${listing.condition ? `
                          Zustand: ${esc(listing.condition)}
                          ` : ''} +
                          +
                          ` : ''} +
                          `; + }, + }, + + 'ebay-de': { + name: 'eBay Professional', + wrap(listing) { + return `
                          +
                          +

                          ${esc(listing.title)}

                          +
                          +

                          ${richDescription(listing)}

                          + ${listing.brand || listing.size ? `
                          +
                          Artikelmerkmale
                          + + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                          Marke${esc(listing.brand)}
                          Typ${esc(listing.category)}
                          Größe${esc(listing.size)}
                          Farbe${esc(listing.color)}
                          Zustand${esc(listing.condition)}
                          +
                          ` : ''} +

                          Privatverkauf — keine Garantie, keine Rücknahme.

                          +
                          `; + }, + }, + + etsy: { + name: 'Etsy Handmade', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags : []; + return `
                          +

                          ${richDescription(listing)}

                          + ${listing.brand || listing.size ? `
                          +

                          Details

                          +
                          + ${listing.brand ? `
                          Brand: ${esc(listing.brand)}
                          ` : ''} + ${listing.category ? `
                          Category: ${esc(listing.category)}
                          ` : ''} + ${listing.size ? `
                          Size: ${esc(listing.size)}
                          ` : ''} + ${listing.color ? `
                          Color: ${esc(listing.color)}
                          ` : ''} + ${listing.condition ? `
                          Condition: ${esc(listing.condition)}
                          ` : ''} +
                          +
                          ` : ''} + ${tags.length ? `

                          ${tags.map(t => `${esc(t)}`).join(' ')}

                          ` : ''} +
                          `; + }, + }, +}; + +function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function nl2br(s) { return esc(s || '').replace(/\n/g, '
                          '); } + +const ALLOWED_RICH_TAGS = new Set([ + 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'ol', 'ul', 'li', + 'blockquote', 'a', 'h1', 'h2', 'h3', 'span', 'div', 'sub', 'sup', 'code', 'pre', +]); +const VOID_RICH_TAGS = new Set(['br']); + +function sanitizeStyle(style) { + const safe = []; + for (const declaration of String(style || '').split(';')) { + const [rawName, ...rest] = declaration.split(':'); + const name = String(rawName || '').trim().toLowerCase(); + const value = rest.join(':').trim(); + if (!value) continue; + if (!['color', 'background-color', 'text-align'].includes(name)) continue; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) continue; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) continue; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) continue; + safe.push(`${name}:${value}`); + } + return safe.join(';'); +} + +export function sanitizeRichHtml(input) { + let html = String(input || ''); + html = html + .replace(//g, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)\b[^>]*\/?\s*>/gi, ''); + + return html.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (full, rawTag, rawAttrs) => { + const tag = String(rawTag || '').toLowerCase(); + const closing = /^<\//.test(full); + if (!ALLOWED_RICH_TAGS.has(tag)) return ''; + if (closing) return VOID_RICH_TAGS.has(tag) ? '' : ``; + + const attrs = []; + const source = String(rawAttrs || ''); + source.replace(/([a-z0-9:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi, (_m, rawName, dq, sq, bare) => { + const name = String(rawName || '').toLowerCase(); + const value = dq ?? sq ?? bare ?? ''; + if (name.startsWith('on')) return ''; + if (name === 'href' && tag === 'a') { + const href = String(value).trim(); + if (/^(https?:|mailto:|tel:|#|\/)/i.test(href)) attrs.push(`href="${esc(href)}"`); + } else if (name === 'target' && tag === 'a' && ['_blank', '_self'].includes(value)) { + attrs.push(`target="${value}"`); + } else if (name === 'rel' && tag === 'a') { + attrs.push('rel="noopener noreferrer"'); + } else if (name === 'style') { + const safeStyle = sanitizeStyle(value); + if (safeStyle) attrs.push(`style="${esc(safeStyle)}"`); + } else if (name === 'class' && /^ql-(align|indent|size|font|color|background)-[a-z0-9-]+$/i.test(value)) { + attrs.push(`class="${esc(value)}"`); + } + return ''; + }); + if (tag === 'a' && attrs.some(attr => attr.startsWith('target="_blank"')) && !attrs.some(attr => attr.startsWith('rel='))) { + attrs.push('rel="noopener noreferrer"'); + } + return `<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''}>`; + }); +} + +function richDescription(listing) { + const html = sanitizeRichHtml(listing?.description_html || ''); + return html || nl2br(listing?.description || ''); +} + +export function wrapInTemplate(listing, platformId) { + const tpl = TEMPLATES[platformId]; + if (!tpl) return `

                          ${richDescription(listing)}

                          `; + return tpl.wrap(listing); +} + +export function getHtmlTemplates() { + return Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-batch-jobs.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-batch-jobs.mjs new file mode 100644 index 0000000..662b7b7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-batch-jobs.mjs @@ -0,0 +1,344 @@ +import { randomUUID } from 'crypto'; +import { basename, dirname, extname, resolve } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { db, createImageEditVersion, getImageEditRoot } from './db.mjs'; +import { renderImageVersion, resolveEditableImage } from './image-editor.mjs'; + +const uploadRoot = resolve(UPLOADS_DIR); +const TERMINAL_ITEM = new Set(['completed', 'failed', 'cancelled']); +const TERMINAL_JOB = new Set(['completed', 'partial', 'failed', 'cancelled']); +let loopTimer = null; +let processing = false; + +function jsonParse(value, fallback = {}) { + try { return JSON.parse(value || ''); } catch { return fallback; } +} + +function normalizePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +function clamp(value, min, max, fallback = min) { + const number = Number(value); + return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback; +} + +function slug(value, fallback = 'bild') { + const clean = String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); + return clean || fallback; +} + +function parseProfile(row) { + if (!row) return null; + return { + ...row, + is_builtin: Number(row.is_builtin || 0) === 1, + operations: jsonParse(row.operations_json, {}), + }; +} + +function parseItem(row) { + if (!row) return null; + return { ...row, settings: jsonParse(row.settings_json, {}) }; +} + +function parseJob(row, { includeItems = false } = {}) { + if (!row) return null; + const job = { + ...row, + pause_requested: Number(row.pause_requested || 0) === 1, + cancel_requested: Number(row.cancel_requested || 0) === 1, + settings: jsonParse(row.settings_json, {}), + }; + if (includeItems) job.items = db.prepare('SELECT * FROM image_batch_job_items WHERE job_id = ? ORDER BY created_at, id').all(job.id).map(parseItem); + return job; +} + +export function listImageBatchProfiles() { + return db.prepare('SELECT * FROM image_batch_profiles ORDER BY is_builtin DESC, name COLLATE NOCASE').all().map(parseProfile); +} + +export function createImageBatchProfile(data = {}) { + const name = String(data.name || '').trim().slice(0, 80); + if (!name) throw new Error('Profilname fehlt.'); + const description = String(data.description || '').trim().slice(0, 300); + const operations = data.operations && typeof data.operations === 'object' ? data.operations : {}; + const filenamePattern = String(data.filename_pattern || '{name}-{profile}-{index}').trim().slice(0, 120) || '{name}-{profile}-{index}'; + const key = `custom-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const result = db.prepare(`INSERT INTO image_batch_profiles + (key, name, description, operations_json, filename_pattern, is_builtin) VALUES (?, ?, ?, ?, ?, 0)`) + .run(key, name, description, JSON.stringify(operations), filenamePattern); + return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(result.lastInsertRowid)); +} + +export function updateImageBatchProfile(id, data = {}) { + const current = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); + if (!current) throw new Error('Produktionsprofil wurde nicht gefunden.'); + const name = String(data.name ?? current.name).trim().slice(0, 80); + if (!name) throw new Error('Profilname fehlt.'); + const description = String(data.description ?? current.description ?? '').trim().slice(0, 300); + const operations = data.operations && typeof data.operations === 'object' ? data.operations : current.operations; + const filenamePattern = String(data.filename_pattern ?? current.filename_pattern ?? '{name}-{profile}-{index}').trim().slice(0, 120); + db.prepare(`UPDATE image_batch_profiles SET name = ?, description = ?, operations_json = ?, filename_pattern = ?, updated_at = datetime('now') WHERE id = ?`) + .run(name, description, JSON.stringify(operations), filenamePattern, Number(id)); + return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); +} + +export function deleteImageBatchProfile(id) { + const profile = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); + if (!profile) return { ok: true, deleted: 0 }; + if (profile.is_builtin) throw new Error('Integrierte Produktionsprofile können nicht gelöscht werden.'); + const inUse = Number(db.prepare("SELECT COUNT(*) AS count FROM image_batch_job_items WHERE profile_id = ? AND status IN ('queued','running')").get(Number(id))?.count || 0); + if (inUse) throw new Error('Das Profil wird noch von einem laufenden Auftrag verwendet.'); + const result = db.prepare('DELETE FROM image_batch_profiles WHERE id = ?').run(Number(id)); + return { ok: true, deleted: result.changes }; +} + +function mergeOperations(profileOperations = {}, overrides = {}) { + const operations = { ...profileOperations }; + operations.resize = { ...(profileOperations.resize || {}) }; + operations.watermark = { ...(profileOperations.watermark || {}) }; + if (overrides.remove_background === true) { + operations.remove_background = true; + operations.background_sensitivity = Math.round(clamp(overrides.background_sensitivity, 0, 100, 45)); + if (!overrides.format) operations.format = 'png'; + } + if (overrides.watermark?.enabled) { + operations.watermark = { + enabled: true, + text: String(overrides.watermark.text || 'Vendoo').slice(0, 80), + position: String(overrides.watermark.position || 'bottom-right'), + opacity: Math.round(clamp(overrides.watermark.opacity, 10, 100, 40)), + }; + } + if (overrides.format) operations.format = String(overrides.format); + if (Number.isFinite(Number(overrides.quality))) operations.quality = Math.round(clamp(overrides.quality, 40, 100, 90)); + return operations; +} + +function filenameFromPattern(pattern, sourcePath, profile, index) { + const sourceName = basename(sourcePath, extname(sourcePath)); + return slug(String(pattern || '{name}-{profile}-{index}') + .replaceAll('{name}', slug(sourceName)) + .replaceAll('{profile}', slug(profile.key || profile.name)) + .replaceAll('{index}', String(index).padStart(2, '0')), + `vendoo-${index}`); +} + +export function createImageBatchJob(data = {}) { + const sourcePaths = [...new Set((Array.isArray(data.source_paths) ? data.source_paths : []).map(normalizePath).filter(Boolean))].slice(0, 200); + const profileIds = [...new Set((Array.isArray(data.profile_ids) ? data.profile_ids : []).map(Number).filter(Number.isFinite))].slice(0, 20); + if (!sourcePaths.length) throw new Error('Mindestens ein Quellbild auswählen.'); + if (!profileIds.length) throw new Error('Mindestens ein Produktionsprofil auswählen.'); + for (const sourcePath of sourcePaths) resolveEditableImage(uploadRoot, sourcePath); + const placeholders = profileIds.map(() => '?').join(','); + const profiles = db.prepare(`SELECT * FROM image_batch_profiles WHERE id IN (${placeholders}) ORDER BY is_builtin DESC, name`).all(...profileIds).map(parseProfile); + if (profiles.length !== profileIds.length) throw new Error('Mindestens ein Produktionsprofil wurde nicht gefunden.'); + + const id = randomUUID(); + const name = String(data.name || `Bildproduktion ${new Date().toLocaleDateString('de-DE')}`).trim().slice(0, 100); + const overrides = data.overrides && typeof data.overrides === 'object' ? data.overrides : {}; + const settings = { source_paths: sourcePaths, profile_ids: profileIds, overrides }; + const totalItems = sourcePaths.length * profiles.length; + const insertJob = db.prepare(`INSERT INTO image_batch_jobs + (id, name, source_count, profile_count, total_items, settings_json) VALUES (?, ?, ?, ?, ?, ?)`); + const insertItem = db.prepare(`INSERT INTO image_batch_job_items + (id, job_id, source_path, profile_id, profile_key, profile_name, settings_json) VALUES (?, ?, ?, ?, ?, ?, ?)`); + const transaction = db.transaction(() => { + insertJob.run(id, name, sourcePaths.length, profiles.length, totalItems, JSON.stringify(settings)); + let itemIndex = 0; + for (const sourcePath of sourcePaths) { + for (const profile of profiles) { + itemIndex += 1; + const operations = mergeOperations(profile.operations, overrides); + const itemSettings = { + operations, + filename_pattern: profile.filename_pattern, + output_filename: filenameFromPattern(profile.filename_pattern, sourcePath, profile, itemIndex), + }; + insertItem.run(randomUUID(), id, sourcePath, profile.id, profile.key, profile.name, JSON.stringify(itemSettings)); + } + } + }); + transaction(); + scheduleLoop(20); + return getImageBatchJob(id); +} + +export function getImageBatchJob(id) { + return parseJob(db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(String(id || '')), { includeItems: true }); +} + +export function paginateImageBatchJobs({ page = 1, pageSize = 10, status = '' } = {}) { + const safeSize = Math.max(5, Math.min(15, Number(pageSize) || 10)); + const requestedPage = Math.max(1, Number(page) || 1); + const statuses = String(status || '').split(',').map(value => value.trim()).filter(Boolean); + const where = statuses.length ? `WHERE status IN (${statuses.map(() => '?').join(',')})` : ''; + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM image_batch_jobs ${where}`).get(...statuses)?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safeSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM image_batch_jobs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`) + .all(...statuses, safeSize, (safePage - 1) * safeSize).map(row => parseJob(row)); + return { items: rows, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary: getImageBatchSummary() }; +} + +export function getImageBatchSummary() { + const rows = db.prepare('SELECT status, COUNT(*) AS count FROM image_batch_jobs GROUP BY status').all(); + const summary = { total: 0, queued: 0, running: 0, paused: 0, completed: 0, partial: 0, failed: 0, cancelled: 0 }; + for (const row of rows) { summary[row.status] = Number(row.count || 0); summary.total += Number(row.count || 0); } + return summary; +} + +function refreshJob(jobId) { + const counts = db.prepare(`SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, + SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued + FROM image_batch_job_items WHERE job_id = ?`).get(jobId); + const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(jobId); + if (!job) return null; + let status = job.status; + let phase = job.phase; + let completedAt = job.completed_at; + if (Number(job.cancel_requested || 0) === 1 && Number(counts.running || 0) === 0) { + status = 'cancelled'; phase = 'cancelled'; completedAt = completedAt || new Date().toISOString(); + } else if (Number(job.pause_requested || 0) === 1 && Number(counts.running || 0) === 0) { + status = 'paused'; phase = 'paused'; + } else if (Number(counts.running || 0) > 0) { + status = 'running'; phase = 'processing'; + } else if (Number(counts.queued || 0) > 0) { + status = 'queued'; phase = 'queued'; + } else if (Number(counts.total || 0) > 0) { + const failed = Number(counts.failed || 0); + const completed = Number(counts.completed || 0); + const cancelled = Number(counts.cancelled || 0); + if (completed === Number(counts.total)) status = 'completed'; + else if (completed > 0 && (failed > 0 || cancelled > 0)) status = 'partial'; + else if (failed > 0) status = 'failed'; + else status = 'cancelled'; + phase = status; completedAt = completedAt || new Date().toISOString(); + } + db.prepare(`UPDATE image_batch_jobs SET status = ?, phase = ?, completed_items = ?, failed_items = ?, cancelled_items = ?, completed_at = ?, updated_at = datetime('now') WHERE id = ?`) + .run(status, phase, Number(counts.completed || 0), Number(counts.failed || 0), Number(counts.cancelled || 0), completedAt, jobId); + return getImageBatchJob(jobId); +} + +export function pauseImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status)) return job; + db.prepare("UPDATE image_batch_jobs SET pause_requested = 1, status = CASE WHEN status = 'running' THEN status ELSE 'paused' END, phase = 'pausing', updated_at = datetime('now') WHERE id = ?").run(job.id); + return refreshJob(job.id); +} + +export function resumeImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status) && !['failed', 'partial'].includes(job.status)) return job; + db.prepare("UPDATE image_batch_jobs SET pause_requested = 0, cancel_requested = 0, status = 'queued', phase = 'queued', completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id); + scheduleLoop(20); + return refreshJob(job.id); +} + +export function cancelImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status)) return job; + const tx = db.transaction(() => { + db.prepare("UPDATE image_batch_jobs SET cancel_requested = 1, pause_requested = 0, phase = 'cancelling', updated_at = datetime('now') WHERE id = ?").run(job.id); + db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(job.id); + }); + tx(); + return refreshJob(job.id); +} + +export function retryImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + const result = db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE job_id = ? AND status IN ('failed','cancelled')").run(job.id); + if (!result.changes) throw new Error('Dieser Auftrag enthält keine wiederholbaren Positionen.'); + db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, error_message = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id); + scheduleLoop(20); + return refreshJob(job.id); +} + +export function retryImageBatchItem(jobId, itemId) { + const item = parseItem(db.prepare('SELECT * FROM image_batch_job_items WHERE id = ? AND job_id = ?').get(String(itemId), String(jobId))); + if (!item) throw new Error('Bildposition wurde nicht gefunden.'); + if (!['failed', 'cancelled'].includes(item.status)) throw new Error('Nur fehlgeschlagene oder abgebrochene Positionen können wiederholt werden.'); + db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(item.id); + db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(jobId); + scheduleLoop(20); + return refreshJob(jobId); +} + +export function deleteImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) return { ok: true, deleted: 0 }; + if (!TERMINAL_JOB.has(job.status)) throw new Error('Laufende Bildaufträge können nicht gelöscht werden.'); + const result = db.prepare('DELETE FROM image_batch_jobs WHERE id = ?').run(job.id); + return { ok: true, deleted: result.changes }; +} + +export function getImageBatchOutputPaths(id) { + return db.prepare("SELECT output_path FROM image_batch_job_items WHERE job_id = ? AND status = 'completed' AND output_path IS NOT NULL ORDER BY created_at, id") + .all(String(id || '')).map(row => row.output_path).filter(Boolean); +} + +async function processNextItem() { + if (processing) return; + const item = parseItem(db.prepare(`SELECT i.* FROM image_batch_job_items i + JOIN image_batch_jobs j ON j.id = i.job_id + WHERE i.status = 'queued' AND j.pause_requested = 0 AND j.cancel_requested = 0 AND j.status IN ('queued','running') + ORDER BY j.created_at ASC, i.created_at ASC LIMIT 1`).get()); + if (!item) return; + processing = true; + try { + const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(item.job_id); + db.prepare("UPDATE image_batch_jobs SET status = 'running', phase = 'processing', started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?").run(item.job_id); + db.prepare("UPDATE image_batch_job_items SET status = 'running', phase = 'rendering', started_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(item.id); + const outputDirectory = `batch/${String(item.job_id).slice(0, 8)}`; + const rootPath = getImageEditRoot(item.source_path) || item.source_path; + const result = await renderImageVersion({ + uploadRoot, + sourcePath: item.source_path, + operations: item.settings.operations || {}, + context: 'batch', + rootPath, + recordVersion: createImageEditVersion, + outputDirectory, + outputFilename: item.settings.output_filename, + }); + db.prepare("UPDATE image_batch_job_items SET status = 'completed', phase = 'completed', output_path = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?") + .run(result.filename, item.id); + } catch (error) { + db.prepare("UPDATE image_batch_job_items SET status = 'failed', phase = 'failed', error_message = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?") + .run(String(error?.message || error || 'Bild konnte nicht verarbeitet werden.').slice(0, 1000), item.id); + } finally { + refreshJob(item.job_id); + processing = false; + scheduleLoop(20); + } +} + +function scheduleLoop(delay = 500) { + if (loopTimer) clearTimeout(loopTimer); + loopTimer = setTimeout(async () => { + loopTimer = null; + await processNextItem(); + scheduleLoop(500); + }, delay); + loopTimer.unref?.(); +} + +export function startImageBatchJobLoop() { + db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', started_at = NULL, updated_at = datetime('now') WHERE status = 'running'").run(); + db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = COALESCE(completed_at, datetime('now')), updated_at = datetime('now') WHERE status = 'queued' AND job_id IN (SELECT id FROM image_batch_jobs WHERE cancel_requested = 1)").run(); + db.prepare("UPDATE image_batch_jobs SET status = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, phase = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, updated_at = datetime('now') WHERE status = 'running'").run(); + for (const row of db.prepare("SELECT id FROM image_batch_jobs WHERE status IN ('queued','running','paused','cancelled')").all()) refreshJob(row.id); + scheduleLoop(200); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-editor.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-editor.mjs new file mode 100644 index 0000000..23f9b04 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/image-editor.mjs @@ -0,0 +1,305 @@ +import sharp from 'sharp'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, statSync, unlinkSync } from 'fs'; +import { dirname, extname, join, resolve, sep } from 'path'; + +const SUPPORTED_FORMATS = new Set(['jpeg', 'webp', 'png']); +const WATERMARK_POSITIONS = new Set(['bottom-right', 'bottom-left', 'top-right', 'top-left', 'center']); +const MAX_PIXELS = 60_000_000; +const RESIZE_FITS = new Set(['inside', 'contain', 'cover']); + +function clamp(value, min, max, fallback = min) { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function normalizeRelativePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +export function resolveEditableImage(uploadRoot, relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized || normalized.includes('\0')) throw new Error('Bildpfad fehlt.'); + const root = resolve(uploadRoot); + const absolute = resolve(root, normalized); + if (absolute !== root && !absolute.startsWith(root + sep)) throw new Error('Ungültiger Bildpfad.'); + if (!existsSync(absolute) || !statSync(absolute).isFile()) throw new Error('Bilddatei wurde nicht gefunden.'); + if (!['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif'].includes(extname(absolute).toLowerCase())) { + throw new Error('Dieses Bildformat kann nicht bearbeitet werden.'); + } + return { normalized, absolute, root }; +} + +export function normalizeEditorOperations(input = {}) { + const cropInput = input.crop && typeof input.crop === 'object' ? input.crop : null; + const crop = cropInput ? { + x: clamp(cropInput.x, 0, 1, 0), + y: clamp(cropInput.y, 0, 1, 0), + width: clamp(cropInput.width, 0.01, 1, 1), + height: clamp(cropInput.height, 0.01, 1, 1), + } : null; + if (crop) { + crop.width = Math.min(crop.width, 1 - crop.x); + crop.height = Math.min(crop.height, 1 - crop.y); + } + + const resizeInput = input.resize && typeof input.resize === 'object' ? input.resize : {}; + const width = Math.round(clamp(resizeInput.width, 0, 12000, 0)); + const height = Math.round(clamp(resizeInput.height, 0, 12000, 0)); + const resizeFit = RESIZE_FITS.has(String(resizeInput.fit || '').toLowerCase()) ? String(resizeInput.fit).toLowerCase() : 'inside'; + const resizeBackground = /^#[0-9a-f]{6}$/i.test(String(resizeInput.background || '')) ? String(resizeInput.background) : '#ffffff'; + + const rotation = Math.round(clamp(input.rotation, -1080, 1080, 0) / 90) * 90; + const watermarkInput = input.watermark && typeof input.watermark === 'object' ? input.watermark : {}; + const watermarkText = String(watermarkInput.text || 'Vendoo').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); + const watermarkPosition = WATERMARK_POSITIONS.has(String(watermarkInput.position || '')) ? String(watermarkInput.position) : 'bottom-right'; + return { + rotation: ((rotation % 360) + 360) % 360, + straighten: clamp(input.straighten, -15, 15, 0), + flip_h: Boolean(input.flip_h), + flip_v: Boolean(input.flip_v), + crop, + exposure: clamp(input.exposure, -2, 2, 0), + brightness: clamp(input.brightness, -100, 100, 0), + contrast: clamp(input.contrast, -100, 100, 0), + highlights: clamp(input.highlights, -100, 100, 0), + shadows: clamp(input.shadows, -100, 100, 0), + temperature: clamp(input.temperature, -100, 100, 0), + saturation: clamp(input.saturation, -100, 100, 0), + sharpness: clamp(input.sharpness, 0, 100, 0), + remove_background: Boolean(input.remove_background), + background_sensitivity: Math.round(clamp(input.background_sensitivity, 0, 100, 45)), + watermark: { + enabled: Boolean(watermarkInput.enabled) && Boolean(watermarkText), + text: watermarkText || 'Vendoo', + position: watermarkPosition, + opacity: Math.round(clamp(watermarkInput.opacity, 10, 100, 40)), + }, + resize: { width, height, fit: resizeFit, background: resizeBackground }, + format: SUPPORTED_FORMATS.has(String(input.format || '').toLowerCase()) ? String(input.format).toLowerCase() : 'jpeg', + quality: Math.round(clamp(input.quality, 40, 100, 90)), + }; +} + +function applyToneAdjustments(data, channels, operations) { + const exposure = 2 ** operations.exposure; + const brightness = operations.brightness / 100 * 0.22; + const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100)); + const contrast = (1 + contrastValue) / (1 - contrastValue); + const highlights = operations.highlights / 100; + const shadows = operations.shadows / 100; + const temperature = operations.temperature / 100; + const saturation = 1 + operations.saturation / 100; + + for (let index = 0; index < data.length; index += channels) { + let red = data[index] / 255; + let green = data[index + 1] / 255; + let blue = data[index + 2] / 255; + + red = red * exposure + brightness; + green = green * exposure + brightness; + blue = blue * exposure + brightness; + + red = (red - 0.5) * contrast + 0.5; + green = (green - 0.5) * contrast + 0.5; + blue = (blue - 0.5) * contrast + 0.5; + + const luminanceBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722)); + const shadowWeight = (1 - luminanceBefore) ** 2; + const highlightWeight = luminanceBefore ** 2; + const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28; + const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38; + red += shadowDelta + highlightDelta; + green += shadowDelta + highlightDelta; + blue += shadowDelta + highlightDelta; + + red += temperature * 0.12; + green += temperature * 0.015; + blue -= temperature * 0.12; + + const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; + red = luminance + (red - luminance) * saturation; + green = luminance + (green - luminance) * saturation; + blue = luminance + (blue - luminance) * saturation; + + data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255); + data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255); + data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255); + } + return data; +} + +async function applyGeometry(sourcePath, operations) { + // Sharp applies only one rotation per pipeline. Keep EXIF orientation, + // flipping and the combined user rotation/straightening in separate stages. + let rendered = await sharp(sourcePath, { failOn: 'none', limitInputPixels: MAX_PIXELS }) + .rotate() + .toBuffer({ resolveWithObject: true }); + + if (operations.flip_h || operations.flip_v) { + let flipStage = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); + if (operations.flip_v) flipStage = flipStage.flip(); + if (operations.flip_h) flipStage = flipStage.flop(); + rendered = await flipStage.toBuffer({ resolveWithObject: true }); + } + + const angle = operations.rotation + operations.straighten; + if (Math.abs(angle) >= 0.05) { + rendered = await sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }) + .rotate(angle, { background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .toBuffer({ resolveWithObject: true }); + } + + let current = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); + + if (operations.crop) { + const { width, height } = rendered.info; + const left = Math.max(0, Math.min(width - 1, Math.round(operations.crop.x * width))); + const top = Math.max(0, Math.min(height - 1, Math.round(operations.crop.y * height))); + const cropWidth = Math.max(1, Math.min(width - left, Math.round(operations.crop.width * width))); + const cropHeight = Math.max(1, Math.min(height - top, Math.round(operations.crop.height * height))); + current = current.extract({ left, top, width: cropWidth, height: cropHeight }); + } + + if (operations.resize.width || operations.resize.height) { + const fit = operations.resize.fit || 'inside'; + current = current.resize({ + width: operations.resize.width || undefined, + height: operations.resize.height || undefined, + fit, + background: operations.resize.background || '#ffffff', + withoutEnlargement: fit === 'inside', + position: 'centre', + kernel: sharp.kernel.lanczos3, + }); + } + return current; +} + + +function removeUniformBackground(data, channels, width, height, sensitivity = 45) { + if (channels < 4 || !width || !height) return data; + const indexes = [0, (width - 1) * channels, ((height - 1) * width) * channels, (width * height - 1) * channels]; + const background = indexes.reduce((sum, index) => ({ + r: sum.r + data[index], + g: sum.g + data[index + 1], + b: sum.b + data[index + 2], + }), { r: 0, g: 0, b: 0 }); + background.r /= indexes.length; + background.g /= indexes.length; + background.b /= indexes.length; + const level = Math.max(0, Math.min(100, Number(sensitivity) || 0)); + const solidThreshold = 24 + level * 2.1; + const featherWidth = 45 + level * 1.2; + const featherEnd = solidThreshold + featherWidth; + for (let index = 0; index < data.length; index += channels) { + const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); + if (distance <= solidThreshold) data[index + 3] = 0; + else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth); + } + return data; +} + +function escapeXmlText(value) { + return String(value || '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); +} + +function createWatermarkSvg(width, height, watermark) { + const margin = Math.max(10, Math.round(Math.min(width, height) * 0.025)); + const fontSize = Math.max(16, Math.round(width * 0.04)); + const position = watermark.position || 'bottom-right'; + let x = width - margin; + let y = height - margin; + let anchor = 'end'; + let baseline = 'auto'; + if (position.includes('left')) { x = margin; anchor = 'start'; } + if (position.includes('top')) { y = margin + fontSize; baseline = 'auto'; } + if (position === 'center') { x = width / 2; y = height / 2; anchor = 'middle'; baseline = 'middle'; } + const opacity = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100)); + const text = escapeXmlText(watermark.text); + return Buffer.from(`${text}`); +} + +export async function renderImageVersion({ uploadRoot, sourcePath, operations: rawOperations, context = 'generator', userId = null, rootPath = null, recordVersion = null, outputDirectory = null, outputFilename = null }) { + const source = resolveEditableImage(uploadRoot, sourcePath); + const operations = normalizeEditorOperations(rawOperations); + const resolvedRootPath = String(rootPath || source.normalized).replace(/\\/g, '/').replace(/^\/+/, '').trim() || source.normalized; + + let pipeline = await applyGeometry(source.absolute, operations); + const raw = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const adjusted = applyToneAdjustments(raw.data, raw.info.channels, operations); + if (operations.remove_background) removeUniformBackground(adjusted, raw.info.channels, raw.info.width, raw.info.height, operations.background_sensitivity); + pipeline = sharp(adjusted, { + raw: { width: raw.info.width, height: raw.info.height, channels: raw.info.channels }, + limitInputPixels: MAX_PIXELS, + }); + + if (operations.sharpness > 0) { + const strength = operations.sharpness / 100; + pipeline = pipeline.sharpen({ sigma: 0.6 + strength * 1.8, m1: 0.5 + strength * 1.5, m2: 1.5 + strength * 2.5 }); + } + if (operations.watermark.enabled && operations.watermark.text) { + pipeline = pipeline.composite([{ input: createWatermarkSvg(raw.info.width, raw.info.height, operations.watermark), top: 0, left: 0 }]); + } + + const extension = operations.format === 'jpeg' ? '.jpg' : `.${operations.format}`; + const sourceFolder = dirname(source.normalized).replace(/\\/g, '/'); + const requestedFolder = normalizeRelativePath(outputDirectory || sourceFolder || '.'); + const safeFolder = requestedFolder === '.' ? '' : requestedFolder.split('/').filter(part => part && part !== '.' && part !== '..').join('/'); + const requestedBase = String(outputFilename || randomUUID()).replace(/\\/g, '-').replace(/[^a-zA-Z0-9äöüÄÖÜß._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) || randomUUID(); + const cleanBase = requestedBase.replace(/\.(jpe?g|png|webp)$/i, ''); + let outputName = `${cleanBase}${extension}`; + let outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; + let outputAbsolute = resolve(uploadRoot, outputRelative); + const resolvedUploadRoot = resolve(uploadRoot); + if (outputAbsolute === resolvedUploadRoot || !outputAbsolute.startsWith(resolvedUploadRoot + sep)) throw new Error('Ungültiger Ausgabeordner.'); + let duplicateIndex = 2; + while (existsSync(outputAbsolute)) { + outputName = `${cleanBase}-${duplicateIndex}${extension}`; + outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; + outputAbsolute = resolve(uploadRoot, outputRelative); + duplicateIndex += 1; + } + mkdirSync(dirname(outputAbsolute), { recursive: true }); + + if (operations.format === 'png') pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }); + else if (operations.format === 'webp') pipeline = pipeline.webp({ quality: operations.quality, smartSubsample: true }); + else pipeline = pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: operations.quality, mozjpeg: true, chromaSubsampling: '4:4:4' }); + + await pipeline.toFile(outputAbsolute); + const metadata = await sharp(outputAbsolute, { failOn: 'none' }).metadata(); + const stats = statSync(outputAbsolute); + const versionData = { + root_path: resolvedRootPath, + source_path: source.normalized, + output_path: outputRelative, + context: String(context || 'generator').slice(0, 40), + operations, + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || operations.format, + quality: operations.quality, + file_size: stats.size, + user_id: userId, + }; + let version; + try { + version = typeof recordVersion === 'function' ? await recordVersion(versionData) : versionData; + } catch (error) { + try { unlinkSync(outputAbsolute); } catch {} + throw error; + } + + return { + filename: outputRelative, + root_path: resolvedRootPath, + version, + metadata: { + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || operations.format, + file_size: stats.size, + }, + operations, + }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/images.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/images.mjs new file mode 100644 index 0000000..842ce99 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/images.mjs @@ -0,0 +1,90 @@ +import sharp from 'sharp'; +import { join } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; + +const uploadsDir = UPLOADS_DIR; + +export async function prepareForAI(filenames, maxSize = 1024) { + const images = []; + for (const filename of filenames) { + const filepath = join(uploadsDir, filename); + const buffer = await sharp(filepath) + .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + images.push({ base64: buffer.toString('base64'), mediaType: 'image/jpeg' }); + } + return images; +} + +export async function removeBackground(filename) { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_nobg.png'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const { data, info } = await image + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const corners = [ + getPixel(data, 0, 0, info.width), + getPixel(data, info.width - 1, 0, info.width), + getPixel(data, 0, info.height - 1, info.width), + getPixel(data, info.width - 1, info.height - 1, info.width), + ]; + + const bgColor = avgColor(corners); + const tolerance = 60; + const result = Buffer.from(data); + + for (let i = 0; i < data.length; i += 4) { + const dr = Math.abs(data[i] - bgColor.r); + const dg = Math.abs(data[i + 1] - bgColor.g); + const db = Math.abs(data[i + 2] - bgColor.b); + if (dr + dg + db < tolerance * 3) { + result[i + 3] = 0; + } + } + + await sharp(result, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png() + .toFile(outPath); + + return newName; +} + +function getPixel(data, x, y, width) { + const idx = (y * width + x) * 4; + return { r: data[idx], g: data[idx + 1], b: data[idx + 2] }; +} + +function avgColor(pixels) { + const sum = pixels.reduce((a, p) => ({ r: a.r + p.r, g: a.g + p.g, b: a.b + p.b }), { r: 0, g: 0, b: 0 }); + const n = pixels.length; + return { r: Math.round(sum.r / n), g: Math.round(sum.g / n), b: Math.round(sum.b / n) }; +} + +export async function addWatermark(filename, text = 'Vendoo') { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_wm$1'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const fontSize = Math.max(16, Math.round(width * 0.04)); + const svg = ` + + ${text} + `; + + await image + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .toFile(outPath); + + return newName; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/mailer.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/mailer.mjs new file mode 100644 index 0000000..655893f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/mailer.mjs @@ -0,0 +1,214 @@ +import nodemailer from 'nodemailer'; + +let transporter = null; + +export function initMailer() { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587'); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + const from = process.env.SMTP_FROM || user; + + if (!host || !user || !pass) { + console.warn('SMTP nicht konfiguriert — Magic Links werden in der Konsole ausgegeben'); + return false; + } + + transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }); + + return true; +} + +function brandedHtml(title, content, buttonText, buttonUrl) { + return ` + + + + + +
                          + + + + +
                          +

                          Vendoo

                          +
                          +

                          ${title}

                          +
                          ${content}
                          + ${buttonUrl ? ` + +

                          Oder kopiere diesen Link:
                          + ${buttonUrl}

                          ` : ''} +
                          +

                          Vendoo — Marketplace Listing Generator

                          +
                          +
                          +`; +} + +export async function sendMagicLink(email, token, baseUrl, type = 'login') { + const url = `${baseUrl}/auth/magic?token=${token}`; + + const isInvite = type === 'invite'; + const title = isInvite ? 'Du wurdest eingeladen!' : 'Dein Login-Link'; + const content = isInvite + ? `

                          Du wurdest zum Vendoo eingeladen. Klicke auf den Button, um dein Konto zu aktivieren und loszulegen.

                          Der Link ist 48 Stunden gültig.

                          ` + : `

                          Klicke auf den Button, um dich einzuloggen. Kein Passwort nötig.

                          Der Link ist 15 Minuten gültig und kann nur einmal verwendet werden.

                          `; + const buttonText = isInvite ? 'Einladung annehmen' : 'Jetzt einloggen'; + + const html = brandedHtml(title, content, buttonText, url); + const subject = isInvite ? 'Vendoo — Du wurdest eingeladen' : 'Vendoo — Dein Login-Link'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`MAGIC LINK (${type}) für ${email}:`); + console.log(url); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true, url }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Email senden fehlgeschlagen:', err.message); + console.log(`Fallback — Magic Link für ${email}: ${url}`); + return { sent: false, error: err.message, url }; + } +} + +export async function sendCredentials(email, password, name, baseUrl) { + const title = 'Dein Account wurde erstellt'; + const content = `

                          Hallo${name ? ' ' + name : ''},

                          +

                          Ein Admin hat ein Konto für dich erstellt. Hier sind deine Zugangsdaten:

                          + + + +
                          E-Mail${email}
                          Passwort${password}
                          +

                          Bitte ändere dein Passwort nach dem ersten Login.

                          `; + const buttonText = 'Jetzt einloggen'; + const buttonUrl = baseUrl + '/login.html'; + + const html = brandedHtml(title, content, buttonText, buttonUrl); + const subject2 = 'Vendoo — Dein Account wurde erstellt'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`CREDENTIALS für ${email}:`); + console.log(`Passwort: ${password}`); + console.log(`Login: ${baseUrl}/login.html`); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: subject2, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Credentials email failed:', err.message); + console.log(`Fallback — Credentials für ${email}: Passwort=${password}`); + return { sent: false, error: err.message }; + } +} + +export async function sendNotificationEmail(email, subject, title, content) { + const html = brandedHtml(title, content, null, null); + + if (!transporter) { + console.log(`Notification for ${email}: ${subject}`); + return { sent: false }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: `Vendoo — ${subject}`, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Notification email failed:', err.message); + return { sent: false, error: err.message }; + } +} + +export function isMailerConfigured() { + return transporter !== null; +} + +export function reinitMailer(config) { + if (!config || !config.host || !config.user || !config.pass) { + transporter = null; + return false; + } + const port = parseInt(config.port || '587'); + transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: { user: config.user, pass: config.pass }, + tls: { rejectUnauthorized: false }, + }); + return true; +} + +export function getMailerConfig() { + if (!transporter || !transporter.options) { + return { + host: process.env.SMTP_HOST || '', + port: process.env.SMTP_PORT || '587', + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS ? '••••••••' : '', + from: process.env.SMTP_FROM || process.env.SMTP_USER || '', + configured: transporter !== null, + }; + } + const opts = transporter.options; + return { + host: opts.host || '', + port: String(opts.port || '587'), + user: opts.auth?.user || '', + pass: opts.auth?.pass ? '••••••••' : '', + from: process.env.SMTP_FROM || opts.auth?.user || '', + configured: true, + }; +} + +export async function testMailer(toEmail) { + if (!transporter) throw new Error('SMTP nicht konfiguriert'); + const html = ` + + +

                          Vendoo — SMTP Test

                          +

                          Diese E-Mail bestätigt, dass deine SMTP-Konfiguration korrekt funktioniert.

                          +

                          Gesendet: ${new Date().toLocaleString('de-DE')}

                          +`; + + await transporter.sendMail({ + from: process.env.SMTP_FROM || transporter.options?.auth?.user, + to: toEmail, + subject: 'Vendoo — SMTP Testmail', + html, + }); + return { sent: true }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/operations-center.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/operations-center.mjs new file mode 100644 index 0000000..ddcccaf --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/operations-center.mjs @@ -0,0 +1,391 @@ +import Database from 'better-sqlite3'; +import { createHash, randomUUID } from 'crypto'; +import { + existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, + unlinkSync, copyFileSync, rmSync, +} from 'fs'; +import { join, relative, dirname, basename, resolve, sep } from 'path'; +import { db } from './db.mjs'; +import { APP_ROOT, DATA_ROOT, DB_PATH, UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, APP_VERSION } from './runtime-paths.mjs'; +import { createZip } from './zip.mjs'; +import { readZip } from './archive.mjs'; + +const AUTO_BACKUP_DIR = join(BACKUP_DIR, 'automatic'); +const MANUAL_BACKUP_DIR = join(BACKUP_DIR, 'manual'); +const STAGING_DIR = join(OPERATIONS_DIR, 'staging'); +const UPDATE_DIR = join(OPERATIONS_DIR, 'updates'); +const PENDING_FILE = join(OPERATIONS_DIR, 'pending-operation.json'); +const LAST_OPERATION_FILE = join(OPERATIONS_DIR, 'last-operation.json'); + +for (const directory of [BACKUP_DIR, AUTO_BACKUP_DIR, MANUAL_BACKUP_DIR, STAGING_DIR, UPDATE_DIR, dirname(PENDING_FILE)]) { + mkdirSync(directory, { recursive: true }); +} + +function nowIso() { return new Date().toISOString(); } +function localDateKey(date = new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } +function safeJson(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } } +function hash(data) { return createHash('sha256').update(data).digest('hex'); } +function cleanLabel(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]+/g, '').slice(0, 60); } +function portable(path) { return String(path || '').replace(/\\/g, '/'); } +function fileSize(path) { try { return statSync(path).size; } catch { return 0; } } +function isInsideAllowedRoot(path) { + const resolvedPath = resolve(path); + return [APP_ROOT, DATA_ROOT].some(root => resolvedPath === resolve(root) || resolvedPath.startsWith(resolve(root) + sep)); +} +function ensureInsideAllowedRoot(path) { if (!isInsideAllowedRoot(path)) throw new Error('Pfad liegt außerhalb der erlaubten Vendoo-Verzeichnisse.'); return path; } + +function walkFiles(base, prefix = '') { + const result = []; + if (!existsSync(base)) return result; + for (const name of readdirSync(base)) { + const absolute = join(base, name); + const relativeName = portable(join(prefix, name)); + const stat = statSync(absolute); + if (stat.isDirectory()) result.push(...walkFiles(absolute, relativeName)); + else if (stat.isFile()) result.push({ absolute, name: relativeName, size: stat.size }); + } + return result; +} + +function getOperationSettings() { + const rows = db.prepare('SELECT key, value FROM operation_settings').all(); + const values = Object.fromEntries(rows.map(row => [row.key, row.value])); + return { + auto_backup_enabled: values.auto_backup_enabled === '1', + auto_backup_hour: Math.max(0, Math.min(23, Number(values.auto_backup_hour ?? 3))), + auto_backup_retention: Math.max(1, Math.min(30, Number(values.auto_backup_retention ?? 7))), + include_uploads_automatic: values.include_uploads_automatic !== '0', + update_manifest_url: values.update_manifest_url || process.env.VENDOO_UPDATE_MANIFEST_URL || '', + last_auto_backup_date: values.last_auto_backup_date || '', + }; +} + +export function updateOperationSettings(patch = {}) { + const allowed = new Set(['auto_backup_enabled', 'auto_backup_hour', 'auto_backup_retention', 'include_uploads_automatic', 'update_manifest_url']); + const stmt = db.prepare(`INSERT INTO operation_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`); + const tx = db.transaction(() => { + for (const [key, raw] of Object.entries(patch || {})) { + if (!allowed.has(key)) continue; + let value = raw; + if (['auto_backup_enabled', 'include_uploads_automatic'].includes(key)) value = raw ? '1' : '0'; + if (key === 'auto_backup_hour') value = String(Math.max(0, Math.min(23, Number(raw || 0)))); + if (key === 'auto_backup_retention') value = String(Math.max(1, Math.min(30, Number(raw || 7)))); + if (key === 'update_manifest_url') value = String(raw || '').trim().slice(0, 1000); + stmt.run(key, String(value)); + } + }); + tx(); + return getOperationSettings(); +} + +async function createDatabaseSnapshot(destination) { + try { db.pragma('wal_checkpoint(PASSIVE)'); } catch {} + await db.backup(destination); + const checkDb = new Database(destination, { readonly: true }); + try { + const integrity = checkDb.pragma('integrity_check', { simple: true }); + if (integrity !== 'ok') throw new Error(`SQLite-Integritätsprüfung: ${integrity}`); + } finally { checkDb.close(); } +} + +function backupFileRows() { + return db.prepare('SELECT * FROM operation_backups ORDER BY created_at DESC, id DESC').all().map(row => { + const { file_path: filePath, options_json: _optionsJson, manifest_json: _manifestJson, ...safeRow } = row; + return { + ...safeRow, + options: safeJson(row.options_json), + manifest: safeJson(row.manifest_json), + exists: existsSync(filePath), + }; + }); +} + +function rotateAutomaticBackups(retention) { + const rows = db.prepare("SELECT * FROM operation_backups WHERE kind = 'automatic' ORDER BY created_at DESC, id DESC").all(); + for (const row of rows.slice(retention)) { + try { if (existsSync(row.file_path)) unlinkSync(row.file_path); } catch {} + db.prepare('DELETE FROM operation_backups WHERE id = ?').run(row.id); + } +} + +export async function createBackup({ kind = 'manual', includeUploads = true, includeConfig = false, label = '', automatic = false } = {}) { + const id = randomUUID(); + const createdAt = nowIso(); + const safeKind = automatic ? 'automatic' : (kind === 'database' ? 'database' : 'manual'); + const directory = automatic ? AUTO_BACKUP_DIR : MANUAL_BACKUP_DIR; + const stamp = createdAt.replace(/[:.]/g, '-'); + const suffix = cleanLabel(label) ? `-${cleanLabel(label).replace(/\s+/g, '-')}` : ''; + const filename = `vendoo-${safeKind}-${stamp}${suffix}.zip`; + const outputPath = join(directory, filename); + const tempDb = join(STAGING_DIR, `backup-${id}.db`); + await createDatabaseSnapshot(tempDb); + + const sourceFiles = [{ absolute: tempDb, name: 'db/vendoo.db', size: fileSize(tempDb), role: 'database' }]; + if (includeUploads) sourceFiles.push(...walkFiles(UPLOADS_DIR, 'uploads').map(file => ({ ...file, role: 'upload' }))); + if (includeConfig && existsSync(join(APP_ROOT, '.env'))) sourceFiles.push({ absolute: join(APP_ROOT, '.env'), name: 'config/.env', size: fileSize(join(APP_ROOT, '.env')), role: 'config-secret' }); + for (const name of ['package.json', '.env.example']) { + const absolute = join(APP_ROOT, name); + if (existsSync(absolute)) sourceFiles.push({ absolute, name: `metadata/${name}`, size: fileSize(absolute), role: 'metadata' }); + } + + const maxBytes = 2 * 1024 * 1024 * 1024; + const totalBytes = sourceFiles.reduce((sum, file) => sum + file.size, 0); + if (totalBytes > maxBytes) { + rmSync(tempDb, { force: true }); + throw new Error('Backup überschreitet 2 GB. Bitte Uploads separat sichern oder alte Dateien bereinigen.'); + } + + const payloadFiles = []; + const manifestFiles = []; + for (const file of sourceFiles) { + const data = readFileSync(ensureInsideAllowedRoot(file.absolute)); + payloadFiles.push({ name: file.name, data }); + manifestFiles.push({ path: file.name, role: file.role, size: data.length, sha256: hash(data) }); + } + const manifest = { + format: 'vendoo-backup-v1', version: APP_VERSION, id, kind: safeKind, created_at: createdAt, + include_uploads: Boolean(includeUploads), include_config: Boolean(includeConfig), + file_count: manifestFiles.length, total_uncompressed_bytes: totalBytes, files: manifestFiles, + }; + payloadFiles.unshift({ name: 'backup-manifest.json', data: Buffer.from(JSON.stringify(manifest, null, 2), 'utf8') }); + const archive = createZip(payloadFiles); + writeFileSync(outputPath, archive); + rmSync(tempDb, { force: true }); + + const verification = verifyBackupBuffer(archive); + if (!verification.ok) { + rmSync(outputPath, { force: true }); + throw new Error(`Backup-Prüfung fehlgeschlagen: ${verification.errors.join('; ')}`); + } + db.prepare(`INSERT INTO operation_backups + (id, kind, label, file_name, file_path, file_size, sha256, status, verified_at, options_json, manifest_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'verified', datetime('now'), ?, ?, datetime('now'))`).run( + id, safeKind, cleanLabel(label), filename, outputPath, fileSize(outputPath), hash(archive), + JSON.stringify({ includeUploads: Boolean(includeUploads), includeConfig: Boolean(includeConfig) }), JSON.stringify(manifest), + ); + if (automatic) rotateAutomaticBackups(getOperationSettings().auto_backup_retention); + return { id, filename, file_size: fileSize(outputPath), sha256: hash(archive), manifest, verification }; +} + +export function verifyBackupBuffer(buffer) { + const errors = []; + const warnings = []; + let manifest = null; + try { + const { files } = readZip(buffer, { maxEntries: 20000, maxUncompressedBytes: 3 * 1024 * 1024 * 1024 }); + const manifestBuffer = files.get('backup-manifest.json'); + if (!manifestBuffer) throw new Error('backup-manifest.json fehlt.'); + manifest = JSON.parse(manifestBuffer.toString('utf8')); + if (manifest.format !== 'vendoo-backup-v1') errors.push('Unbekanntes Backup-Format.'); + for (const entry of manifest.files || []) { + const data = files.get(entry.path); + if (!data) { errors.push(`Datei fehlt: ${entry.path}`); continue; } + if (data.length !== Number(entry.size)) errors.push(`Dateigröße stimmt nicht: ${entry.path}`); + if (hash(data) !== entry.sha256) errors.push(`Prüfsumme stimmt nicht: ${entry.path}`); + } + const dbData = files.get('db/vendoo.db'); + if (!dbData) errors.push('Datenbank fehlt im Backup.'); + else { + const temp = join(STAGING_DIR, `verify-${randomUUID()}.db`); + writeFileSync(temp, dbData); + try { + const verifyDb = new Database(temp, { readonly: true }); + const integrity = verifyDb.pragma('integrity_check', { simple: true }); + verifyDb.close(); + if (integrity !== 'ok') errors.push(`SQLite-Integrität: ${integrity}`); + } catch (error) { errors.push(`Datenbank kann nicht geöffnet werden: ${error.message}`); } + finally { rmSync(temp, { force: true }); } + } + if (manifest.include_config) warnings.push('Dieses Backup enthält Zugangsdaten aus .env. Sicher aufbewahren.'); + } catch (error) { errors.push(error.message); } + return { ok: errors.length === 0, errors, warnings, manifest }; +} + +export function verifyStoredBackup(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row || !existsSync(row.file_path)) throw new Error('Backup wurde nicht gefunden.'); + const buffer = readFileSync(row.file_path); + const result = verifyBackupBuffer(buffer); + db.prepare(`UPDATE operation_backups SET status = ?, verified_at = datetime('now'), sha256 = ?, file_size = ?, manifest_json = ? WHERE id = ?`).run( + result.ok ? 'verified' : 'invalid', hash(buffer), buffer.length, JSON.stringify(result.manifest || {}), id, + ); + return result; +} + +export function listBackups() { return backupFileRows(); } +export function getBackupPath(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row || !existsSync(row.file_path)) throw new Error('Backup wurde nicht gefunden.'); + return row; +} +export function deleteBackup(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row) return { deleted: 0 }; + try { if (existsSync(row.file_path)) unlinkSync(row.file_path); } catch {} + const result = db.prepare('DELETE FROM operation_backups WHERE id = ?').run(id); + return { deleted: Number(result.changes || 0) }; +} + +function writePendingOperation(operation) { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + writeFileSync(PENDING_FILE, JSON.stringify({ ...operation, created_at: nowIso(), app_version: APP_VERSION }, null, 2), 'utf8'); + return { ...operation, pending_file: PENDING_FILE }; +} + +export async function stageRestore(buffer, originalName = 'backup.zip') { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + const verification = verifyBackupBuffer(buffer); + if (!verification.ok) throw new Error(`Backup ist ungültig: ${verification.errors.join('; ')}`); + const safety = await createBackup({ kind: 'manual', includeUploads: true, includeConfig: false, label: 'vor-wiederherstellung' }); + const id = randomUUID(); + const archivePath = join(STAGING_DIR, `restore-${id}.zip`); + writeFileSync(archivePath, buffer); + return writePendingOperation({ id, type: 'restore', archive_path: archivePath, original_name: basename(originalName), safety_backup_id: safety.id, verified: true, manifest: verification.manifest }); +} + +function versionParts(version) { return String(version || '0').replace(/^v/, '').split(/[.-]/).map(part => Number(part) || 0); } +function compareVersions(left, right) { + const a = versionParts(left); const b = versionParts(right); + for (let i = 0; i < Math.max(a.length, b.length); i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0) ? 1 : -1; } + return 0; +} + +function inspectUpdateArchive(buffer) { + const { entries, files } = readZip(buffer, { maxEntries: 30000, maxUncompressedBytes: 4 * 1024 * 1024 * 1024 }); + const packageNames = [...files.keys()].filter(name => name === 'package.json' || name.endsWith('/vendoo/package.json') || /\/package\.json$/.test(name)); + if (!packageNames.length) throw new Error('Kein Vendoo-package.json im Update gefunden.'); + packageNames.sort((a, b) => a.split('/').length - b.split('/').length); + const packagePath = packageNames[0]; + const packageData = JSON.parse(files.get(packagePath).toString('utf8')); + if (packageData.name !== 'vendoo') throw new Error('Das Paket ist kein Vendoo-Update.'); + const rootPrefix = packagePath.slice(0, -'package.json'.length); + const relativeNames = entries.filter(entry => entry.name.startsWith(rootPrefix)).map(entry => entry.name.slice(rootPrefix.length)); + const forbidden = relativeNames.filter(name => + name === '.env' || name.startsWith('db/vendoo.db') || name.startsWith('uploads/') || + name.startsWith('node_modules/') || name.startsWith('backups/') || name.startsWith('operations/') || + name.startsWith('logs/') || name.startsWith('local-ai/comfyui/') || name.startsWith('local-ai/downloads/') + ); + if (forbidden.length) throw new Error(`Update enthält verbotene Laufzeitdaten: ${forbidden.slice(0, 5).join(', ')}`); + for (const required of ['server.mjs', 'public/index.html', 'public/app.js', 'public/style.css', 'setup.ps1']) { + if (!files.has(rootPrefix + required)) throw new Error(`Pflichtdatei fehlt im Update: ${required}`); + } + return { version: packageData.version, package: packageData, root_prefix: rootPrefix, entries: relativeNames.length }; +} + +export async function stageUpdate(buffer, originalName = 'vendoo-update.zip') { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + const inspection = inspectUpdateArchive(buffer); + if (compareVersions(inspection.version, APP_VERSION) <= 0) throw new Error(`Update-Version ${inspection.version} ist nicht neuer als ${APP_VERSION}.`); + const safety = await createBackup({ kind: 'manual', includeUploads: true, includeConfig: false, label: `vor-update-${inspection.version}` }); + const id = randomUUID(); + const archivePath = join(UPDATE_DIR, `vendoo-update-${inspection.version}-${id}.zip`); + writeFileSync(archivePath, buffer); + db.prepare(`INSERT INTO update_operations (id, version_from, version_to, source_name, archive_path, sha256, status, details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'staged', ?, datetime('now'))`).run( + id, APP_VERSION, inspection.version, basename(originalName), archivePath, hash(buffer), JSON.stringify({ ...inspection, safety_backup_id: safety.id }), + ); + return writePendingOperation({ id, type: 'update', archive_path: archivePath, original_name: basename(originalName), version_from: APP_VERSION, version_to: inspection.version, root_prefix: inspection.root_prefix, safety_backup_id: safety.id, verified: true }); +} + +function syncLastOperationState() { + if (!existsSync(LAST_OPERATION_FILE)) return; + const last = safeJson(readFileSync(LAST_OPERATION_FILE, 'utf8'), null); + if (!last?.id || last.type !== 'update') return; + try { + db.prepare(`UPDATE update_operations SET status = ?, applied_at = COALESCE(applied_at, ?), error_message = ? WHERE id = ?`).run( + last.status || 'applied', last.applied_at || null, last.error_message || null, last.id, + ); + } catch {} +} + +export function getOperationsStatus() { + syncLastOperationState(); + const pending = existsSync(PENDING_FILE) ? safeJson(readFileSync(PENDING_FILE, 'utf8'), null) : null; + const last = existsSync(LAST_OPERATION_FILE) ? safeJson(readFileSync(LAST_OPERATION_FILE, 'utf8'), null) : null; + const latestUpdate = db.prepare('SELECT * FROM update_operations ORDER BY created_at DESC LIMIT 1').get() || null; + const migrations = db.prepare('SELECT * FROM system_migrations ORDER BY applied_at DESC, id DESC').all(); + let safeUpdate = null; + if (latestUpdate) { + const { archive_path: _archivePath, details_json: _detailsJson, ...row } = latestUpdate; + safeUpdate = { ...row, details: safeJson(latestUpdate.details_json) }; + } + return { version: APP_VERSION, pending, last, latest_update: safeUpdate, migrations, settings: getOperationSettings() }; +} + +export function cancelPendingOperation() { + if (!existsSync(PENDING_FILE)) return { cancelled: false }; + const pending = safeJson(readFileSync(PENDING_FILE, 'utf8'), {}); + rmSync(PENDING_FILE, { force: true }); + return { cancelled: true, pending }; +} + +export async function checkForUpdates() { + const settings = getOperationSettings(); + const url = String(settings.update_manifest_url || '').trim(); + if (!url) return { configured: false, current_version: APP_VERSION, message: 'Keine Update-Manifest-URL konfiguriert.' }; + if (!/^https?:\/\//i.test(url)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`); + const manifest = await response.json(); + const latest = String(manifest.version || manifest.latest_version || ''); + if (!latest) throw new Error('Update-Manifest enthält keine Version.'); + return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest }; + } finally { clearTimeout(timeout); } +} + +export function recordExtensionHeartbeat({ clientId, browser, version, platform, capabilities, serverUrl, userAgent } = {}) { + const id = String(clientId || '').trim().slice(0, 120) || randomUUID(); + db.prepare(`INSERT INTO extension_clients + (client_id, browser, version, platform, server_url, user_agent, capabilities_json, last_seen, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(client_id) DO UPDATE SET browser=excluded.browser, version=excluded.version, platform=excluded.platform, + server_url=excluded.server_url, user_agent=excluded.user_agent, capabilities_json=excluded.capabilities_json, last_seen=datetime('now')`).run( + id, String(browser || 'unknown').slice(0, 40), String(version || '').slice(0, 30), String(platform || '').slice(0, 40), + String(serverUrl || '').slice(0, 500), String(userAgent || '').slice(0, 1000), JSON.stringify(capabilities || {}), + ); + return { ok: true, client_id: id, server_version: APP_VERSION, time: nowIso() }; +} + +function manifestVersion(path) { + try { return JSON.parse(readFileSync(path, 'utf8')).version || null; } catch { return null; } +} + +export function getExtensionDiagnostics() { + const packages = { + chrome: { path: join(APP_ROOT, 'extensions', 'chrome', 'manifest.json') }, + edge: { path: join(APP_ROOT, 'extensions', 'edge', 'manifest.json') }, + firefox: { path: join(APP_ROOT, 'extensions', 'firefox', 'manifest.json') }, + safari: { path: join(APP_ROOT, 'extensions', 'safari', 'web-extension', 'manifest.json') }, + }; + for (const item of Object.values(packages)) Object.assign(item, { exists: existsSync(item.path), version: manifestVersion(item.path) }); + const clients = db.prepare(`SELECT *, CAST((julianday('now') - julianday(last_seen)) * 86400 AS INTEGER) AS age_seconds + FROM extension_clients ORDER BY last_seen DESC LIMIT 30`).all().map(row => ({ ...row, capabilities: safeJson(row.capabilities_json) })); + return { expected_version: '1.9.0', packages, clients, connected: clients.filter(client => Number(client.age_seconds) <= 180).length }; +} + +let automaticTimer = null; +export function startAutomaticBackupLoop() { + if (automaticTimer) return; + const tick = async () => { + const settings = getOperationSettings(); + if (!settings.auto_backup_enabled) return; + const now = new Date(); + const date = localDateKey(now); + if (now.getHours() !== settings.auto_backup_hour || settings.last_auto_backup_date === date) return; + try { + await createBackup({ automatic: true, includeUploads: settings.include_uploads_automatic, includeConfig: false, label: 'automatisch' }); + db.prepare(`INSERT INTO operation_settings (key, value, updated_at) VALUES ('last_auto_backup_date', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`).run(date); + } catch (error) { console.error('Automatisches Backup fehlgeschlagen:', error.message); } + }; + automaticTimer = setInterval(tick, 15 * 60 * 1000); + automaticTimer.unref?.(); + const initialTimer = setTimeout(tick, 5000); + initialTimer.unref?.(); +} + +export const operationsPaths = { APP_ROOT, DATA_ROOT, DB_PATH, UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, PENDING_FILE, LAST_OPERATION_FILE }; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/publishing-jobs.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/publishing-jobs.mjs new file mode 100644 index 0000000..e4c14d5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/publishing-jobs.mjs @@ -0,0 +1,259 @@ +import { randomUUID, createHash } from 'crypto'; +import { db, getListing, createPublishLog, updatePublishLog } from './db.mjs'; + +const ACTIVE = new Set(['queued', 'running', 'awaiting_user', 'retry_wait']); +const TERMINAL = new Set(['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked']); +const DIRECT = new Set(['ebay-de', 'etsy']); +let timer = null; +let processing = false; +let processors = null; + +function parseJson(value, fallback = {}) { + try { return JSON.parse(value || ''); } catch { return fallback; } +} +function serialize(row) { + if (!row) return null; + return { ...row, payload: parseJson(row.payload_json), result: parseJson(row.result_json) }; +} +function modeFor(platform) { return DIRECT.has(platform) ? 'api' : 'extension'; } +function keyFor(listingId, platform, action, nonce = '') { + return createHash('sha256').update(`${listingId}|${platform}|${action}|${nonce}`).digest('hex'); +} +function addEvent(job, eventType, message, level = 'info', details = {}) { + db.prepare(`INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message, details_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(job?.id || null, job.listing_id, job.platform, level, eventType, message, JSON.stringify(details || {})); +} +function latestPublished(listingId, platform) { + return db.prepare(`SELECT * FROM publish_log WHERE listing_id = ? AND platform = ? AND status = 'published' ORDER BY id DESC LIMIT 1`).get(listingId, platform); +} +function currentActive(listingId, platform, action) { + return db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND action = ? + AND status IN ('queued','running','awaiting_user','retry_wait') ORDER BY created_at DESC LIMIT 1`).get(listingId, platform, action); +} +export function enqueuePublishingJob({ listingId, platform, action = 'publish', force = false, payload = {}, maxRetries = 3 } = {}) { + const listing = getListing(Number(listingId)); + if (!listing) throw new Error('Artikel nicht gefunden'); + const target = platform || listing.platform; + if (!target) throw new Error('Plattform fehlt'); + const existing = currentActive(listing.id, target, action); + if (existing) return { job: serialize(existing), duplicate: true }; + if (action === 'publish' && !force) { + const live = latestPublished(listing.id, target); + if (live?.external_id) { + const error = new Error('Artikel ist auf dieser Plattform bereits als veröffentlicht protokolliert'); + error.code = 'DUPLICATE_PUBLISH'; + error.existing = live; + throw error; + } + } + const id = randomUUID(); + const mode = modeFor(target); + const status = mode === 'extension' && action === 'publish' ? 'awaiting_user' : 'queued'; + const nonce = force ? randomUUID() : `${new Date().toISOString()}`; + const key = keyFor(listing.id, target, action, nonce); + db.prepare(`INSERT INTO publishing_jobs + (id, listing_id, platform, mode, action, status, phase, idempotency_key, max_retries, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, listing.id, target, mode, action, status, status, key, Math.min(Math.max(Number(maxRetries) || 3, 0), 10), JSON.stringify(payload || {})); + const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(job, 'created', mode === 'api' ? 'Publishing-Auftrag eingereiht' : 'Auftrag wartet auf Browser-Extension'); + return { job, duplicate: false }; +} +export function getPublishingJob(id) { + const row = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id WHERE j.id = ?`).get(id); + if (!row) return null; + const job = serialize(row); + job.events = db.prepare('SELECT * FROM publishing_events WHERE job_id = ? ORDER BY id DESC LIMIT 100').all(id).map(event => ({ ...event, details: parseJson(event.details_json) })); + return job; +} +export function paginatePublishingJobs({ page = 1, pageSize = 10, status = '', platform = '', q = '' } = {}) { + const size = [5, 10, 15, 25, 50].includes(Number(pageSize)) ? Number(pageSize) : 10; + const current = Math.max(1, Number(page) || 1); + const where = []; + const params = []; + if (status) { where.push('j.status = ?'); params.push(status); } + if (platform) { where.push('j.platform = ?'); params.push(platform); } + if (q) { where.push('(l.title LIKE ? OR l.sku LIKE ? OR j.external_id LIKE ? OR j.error_message LIKE ?)'); const needle = `%${q}%`; params.push(needle, needle, needle, needle); } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const total = db.prepare(`SELECT COUNT(*) AS count FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause}`).get(...params).count; + const rows = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause} + ORDER BY CASE j.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 WHEN 'awaiting_user' THEN 2 WHEN 'retry_wait' THEN 3 WHEN 'failed' THEN 4 ELSE 5 END, j.created_at DESC + LIMIT ? OFFSET ?`).all(...params, size, (current - 1) * size); + const summaryRows = db.prepare(`SELECT status, COUNT(*) AS count FROM publishing_jobs GROUP BY status`).all(); + return { + items: rows.map(serialize), + pagination: { page: current, page_size: size, total, total_pages: Math.max(1, Math.ceil(total / size)) }, + summary: Object.fromEntries(summaryRows.map(row => [row.status, row.count])), + }; +} +export function listPublishingEvents({ limit = 100, level = '', platform = '' } = {}) { + const where = []; + const params = []; + if (level) { where.push('e.level = ?'); params.push(level); } + if (platform) { where.push('e.platform = ?'); params.push(platform); } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + return db.prepare(`SELECT e.*, l.title, l.sku FROM publishing_events e JOIN listings l ON l.id = e.listing_id ${clause} ORDER BY e.id DESC LIMIT ?`) + .all(...params, Math.min(Math.max(Number(limit) || 100, 1), 500)).map(row => ({ ...row, details: parseJson(row.details_json) })); +} +export function retryPublishingJob(id) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + if (!['failed', 'blocked', 'cancelled'].includes(job.status)) throw new Error('Nur fehlgeschlagene, blockierte oder abgebrochene Aufträge können wiederholt werden'); + const next = modeFor(job.platform) === 'extension' && job.action === 'publish' ? 'awaiting_user' : 'queued'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, error_code = NULL, error_message = NULL, next_attempt_at = NULL, + completed_at = NULL, updated_at = datetime('now') WHERE id = ?`).run(next, next, id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(updated, 'retry', 'Auftrag erneut eingeplant'); + return updated; +} +export function cancelPublishingJob(id) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + if (TERMINAL.has(job.status)) return serialize(job); + db.prepare(`UPDATE publishing_jobs SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`).run(id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(updated, 'cancelled', 'Auftrag abgebrochen', 'warning'); + return updated; +} +export function deletePublishingJobs(ids = []) { + const safe = [...new Set(ids.map(String).filter(Boolean))]; + if (!safe.length) return 0; + const placeholders = safe.map(() => '?').join(','); + const rows = db.prepare(`SELECT id, status FROM publishing_jobs WHERE id IN (${placeholders})`).all(...safe); + if (rows.some(row => ACTIVE.has(row.status))) throw new Error('Aktive Aufträge können nicht gelöscht werden'); + return db.prepare(`DELETE FROM publishing_jobs WHERE id IN (${placeholders})`).run(...safe).changes; +} +export function claimExtensionJob({ platform = 'vinted', clientId = 'extension' } = {}) { + const job = db.prepare(`SELECT * FROM publishing_jobs WHERE platform = ? AND mode = 'extension' AND status = 'awaiting_user' + ORDER BY created_at LIMIT 1`).get(platform); + if (!job) return null; + db.prepare(`UPDATE publishing_jobs SET claimed_by = ?, claimed_at = datetime('now'), phase = 'claimed', updated_at = datetime('now') WHERE id = ?`).run(clientId, job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + addEvent(updated, 'claimed', 'Auftrag von Browser-Extension übernommen', 'info', { clientId }); + return { ...updated, listing: getListing(updated.listing_id) }; +} +export function completeExtensionJob(id, { status = 'published', externalId = null, externalUrl = null, error = null } = {}) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + const nextStatus = status === 'published' ? 'published' : status === 'draft' ? 'awaiting_user' : 'failed'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url), + error_message = ?, completed_at = CASE WHEN ? IN ('published','failed') THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`) + .run(nextStatus, nextStatus, externalId, externalUrl, error, nextStatus, id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + if (nextStatus === 'published') createPublishLog(updated.listing_id, updated.platform, 'published', externalId, externalUrl); + addEvent(updated, nextStatus, nextStatus === 'published' ? 'Extension-Auftrag als veröffentlicht bestätigt' : (error || 'Extension-Auftrag aktualisiert'), nextStatus === 'failed' ? 'error' : 'info'); + return updated; +} +function markRunning(job) { + db.prepare(`UPDATE publishing_jobs SET status = 'running', phase = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.action, job.id); + return serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); +} +function markSuccess(job, result = {}) { + const status = job.action === 'end' ? 'ended' : (job.action === 'sync' ? (result.status || 'published') : (String(result.status || '').toLowerCase() === 'draft' ? 'drafted' : 'published')); + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = 'completed', external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url), + offer_id = COALESCE(?, offer_id), result_json = ?, error_code = NULL, error_message = NULL, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run(status, result.externalId || result.listingId || null, result.url || null, result.offerId || null, JSON.stringify(result || {}), job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + if (job.action === 'publish') { + createPublishLog(job.listing_id, job.platform, status === 'drafted' ? 'draft' : 'published', updated.external_id, updated.external_url); + const createdLog = db.prepare('SELECT id FROM publish_log WHERE listing_id = ? AND platform = ? ORDER BY id DESC LIMIT 1').get(job.listing_id, job.platform); + if (createdLog) updatePublishLog(createdLog.id, { offer_id: updated.offer_id, sync_status: status, last_synced_at: new Date().toISOString(), updated_at: new Date().toISOString() }); + } else { + const log = latestPublished(job.listing_id, job.platform); + if (log) updatePublishLog(log.id, { + status: job.action === 'end' ? 'ended' : (result.status || log.status), + external_id: updated.external_id, + external_url: updated.external_url, + offer_id: updated.offer_id, + sync_status: result.status || status, + last_synced_at: new Date().toISOString(), + ended_at: job.action === 'end' ? new Date().toISOString() : undefined, + updated_at: new Date().toISOString(), + }); + } + addEvent(updated, 'completed', job.action === 'end' ? 'Angebot beendet' : job.action === 'sync' ? 'Status synchronisiert' : 'Veröffentlichung erfolgreich'); + return updated; +} +function markFailure(job, error) { + const retries = Number(job.retries || 0) + 1; + const canRetry = retries <= Number(job.max_retries || 3); + const delayMinutes = Math.min(30, Math.max(1, 2 ** Math.max(0, retries - 1))); + const status = canRetry ? 'retry_wait' : 'failed'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, retries = ?, error_code = ?, error_message = ?, + next_attempt_at = CASE WHEN ? = 'retry_wait' THEN datetime('now', '+' || ? || ' minutes') ELSE NULL END, + completed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`) + .run(status, status, retries, error?.code || 'PUBLISH_ERROR', error?.message || String(error), status, delayMinutes, status, job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + addEvent(updated, status, error?.message || String(error), 'error', { retries, nextRetryMinutes: canRetry ? delayMinutes : null }); + return updated; +} +async function processOne() { + if (!processors) return; + const row = db.prepare(`SELECT * FROM publishing_jobs WHERE mode = 'api' AND ( + status = 'queued' OR (status = 'retry_wait' AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now'))) + ) ORDER BY created_at LIMIT 1`).get(); + if (!row) return; + const job = markRunning(row); + try { + const listing = getListing(job.listing_id); + if (!listing) throw new Error('Artikel nicht gefunden'); + const result = await processors.process(job, listing); + markSuccess(job, result || {}); + } catch (error) { markFailure(job, error); } +} +export function configurePublishingProcessors(value) { processors = value; } +export function startPublishingJobLoop(intervalMs = 1500) { + if (timer) return; + const tick = async () => { + if (processing) return; + processing = true; + try { await processOne(); } finally { processing = false; } + }; + timer = setInterval(tick, intervalMs); + timer.unref?.(); + tick(); +} + +export function migrateLegacyEbayQueue() { + let rows = []; + try { rows = db.prepare('SELECT * FROM ebay_queue ORDER BY id').all(); } catch { return { migrated: 0, skipped: 0 }; } + let migrated = 0; + let skipped = 0; + for (const row of rows) { + const key = `legacy-ebay-queue-${row.id}`; + const exists = db.prepare('SELECT id FROM publishing_jobs WHERE idempotency_key = ?').get(key); + if (exists) { skipped += 1; continue; } + const statusMap = { pending: 'queued', uploading: 'queued', failed: 'failed', published: 'published' }; + const status = statusMap[row.status] || 'queued'; + const id = randomUUID(); + db.prepare(`INSERT OR IGNORE INTO publishing_jobs + (id, listing_id, platform, mode, action, status, phase, idempotency_key, external_id, external_url, + retries, max_retries, error_message, created_at, completed_at, updated_at) + VALUES (?, ?, 'ebay-de', 'api', 'publish', ?, ?, ?, ?, ?, ?, 3, ?, COALESCE(?, datetime('now')), + CASE WHEN ? IN ('published','failed') THEN COALESCE(?, datetime('now')) ELSE NULL END, datetime('now'))`) + .run(id, row.listing_id, status, row.status === 'uploading' ? 'recovered' : status, key, + row.ebay_item_id || null, row.ebay_url || null, Number(row.retries || 0), row.error || null, + row.created_at || null, status, row.updated_at || row.created_at || null); + const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + if (job) { + addEvent(job, 'legacy_migrated', 'Eintrag aus der bisherigen eBay-Queue übernommen', 'info', { legacyQueueId: row.id, legacyStatus: row.status }); + migrated += 1; + } + } + return { migrated, skipped }; +} + +export function recoverPublishingJobs() { + const changes = db.prepare(`UPDATE publishing_jobs SET status = 'queued', phase = 'recovered', updated_at = datetime('now') WHERE status = 'running'`).run().changes; + return changes; +} +export function getPublishCapabilities() { + return { + platforms: { + 'ebay-de': { mode: 'api', publish: true, update: true, end: true, sync: true }, + etsy: { mode: 'api', publish: true, update: false, end: false, sync: true }, + vinted: { mode: 'extension', publish: true, update: false, end: false, sync: false }, + 'ebay-ka': { mode: 'extension', publish: true, update: false, end: false, sync: false }, + }, + }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/quality-center.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/quality-center.mjs new file mode 100644 index 0000000..85beba8 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/quality-center.mjs @@ -0,0 +1,216 @@ +import sharp from 'sharp'; +import { existsSync } from 'fs'; +import { resolve, sep } from 'path'; +import { db, getListing, getListings } from './db.mjs'; +import { calculateFees } from './fees.mjs'; + +const PLATFORM_PROFILES = { + 'ebay-de': { name: 'eBay', titleMin: 12, titleMax: 80, descriptionMin: 80, recommendedPhotos: 4 }, + vinted: { name: 'Vinted', titleMin: 8, titleMax: 100, descriptionMin: 45, recommendedPhotos: 3 }, + 'ebay-ka': { name: 'Kleinanzeigen', titleMin: 10, titleMax: 65, descriptionMin: 60, recommendedPhotos: 4 }, + etsy: { name: 'Etsy', titleMin: 20, titleMax: 140, descriptionMin: 120, recommendedPhotos: 5 }, +}; + +function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } +function wordTokens(value) { + return new Set(normalizeText(value).toLowerCase().replace(/[^a-z0-9äöüß ]/gi, ' ').split(/\s+/).filter(token => token.length > 2)); +} +function jaccard(a, b) { + const left = wordTokens(a); const right = wordTokens(b); + if (!left.size || !right.size) return 0; + let intersection = 0; + for (const token of left) if (right.has(token)) intersection += 1; + return intersection / (left.size + right.size - intersection); +} +function gradeFor(score) { + if (score >= 92) return 'A'; + if (score >= 82) return 'B'; + if (score >= 70) return 'C'; + if (score >= 55) return 'D'; + return 'E'; +} +function issue({ code, severity = 'recommendation', category, title, detail, points = 0, field = null, action = null }) { + return { code, severity, category, title, detail, points, field, action }; +} +function safeParseJson(value, fallback) { + try { const parsed = JSON.parse(value); return parsed ?? fallback; } catch { return fallback; } +} + +function findDuplicates(listing, allListings) { + const candidates = []; + const title = normalizeText(listing.title); + for (const other of allListings) { + if (Number(other.id) === Number(listing.id)) continue; + const similarity = jaccard(title, other.title); + const sameSku = listing.sku && other.sku && String(listing.sku).toLowerCase() === String(other.sku).toLowerCase(); + if (sameSku || similarity >= 0.72) { + candidates.push({ id: other.id, title: other.title, sku: other.sku, similarity: Math.round(similarity * 100), same_sku: !!sameSku }); + } + } + return candidates.sort((a, b) => Number(b.same_sku) - Number(a.same_sku) || b.similarity - a.similarity).slice(0, 5); +} + +function inspectCore(listing, allListings) { + const issues = []; + const title = normalizeText(listing.title); + const description = normalizeText(listing.description); + const photos = Array.isArray(listing.photos) ? listing.photos.filter(Boolean) : []; + const tags = Array.isArray(listing.tags) ? listing.tags.filter(Boolean) : []; + const profile = PLATFORM_PROFILES[listing.platform] || { name: listing.platform || 'Allgemein', titleMin: 10, titleMax: 100, descriptionMin: 60, recommendedPhotos: 3 }; + + if (!title) issues.push(issue({ code: 'missing_title', severity: 'error', category: 'Inhalt', title: 'Titel fehlt', detail: 'Ohne Titel kann der Artikel nicht zuverlässig veröffentlicht werden.', points: 20, field: 'title' })); + else { + if (title.length < profile.titleMin) issues.push(issue({ code: 'title_too_short', severity: 'warning', category: 'Inhalt', title: 'Titel ist sehr kurz', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.titleMin} Zeichen empfohlen.`, points: 6, field: 'title', action: 'ai_title' })); + if (title.length > profile.titleMax) issues.push(issue({ code: 'title_too_long', severity: 'error', category: 'Plattform', title: 'Titel ist zu lang', detail: `Das aktive Vendoo-Profil sieht höchstens ${profile.titleMax} Zeichen vor.`, points: 10, field: 'title', action: 'ai_title' })); + if (/\b(l@@k|mega|hammer|wow|!!!)\b/i.test(title) || /!{2,}/.test(title)) issues.push(issue({ code: 'title_spam', severity: 'recommendation', category: 'Inhalt', title: 'Titel wirkt werblich', detail: 'Sachliche Titel sind leichter lesbar und wirken vertrauenswürdiger.', points: 2, field: 'title', action: 'ai_title' })); + } + + if (!description) issues.push(issue({ code: 'missing_description', severity: 'error', category: 'Inhalt', title: 'Beschreibung fehlt', detail: 'Zustand, Lieferumfang und relevante Besonderheiten sollten beschrieben werden.', points: 16, field: 'description', action: 'ai_description' })); + else { + if (description.length < profile.descriptionMin) issues.push(issue({ code: 'description_too_short', severity: 'warning', category: 'Inhalt', title: 'Beschreibung ist knapp', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.descriptionMin} Zeichen empfohlen.`, points: 6, field: 'description', action: 'ai_description' })); + if (!/[.!?]$/.test(description)) issues.push(issue({ code: 'description_unfinished', severity: 'recommendation', category: 'Inhalt', title: 'Beschreibung wirkt unvollständig', detail: 'Ein sauberer Abschluss verbessert die Lesbarkeit.', points: 1, field: 'description' })); + } + + if (!photos.length) issues.push(issue({ code: 'missing_photos', severity: 'error', category: 'Bilder', title: 'Keine Bilder vorhanden', detail: 'Mindestens ein aussagekräftiges Originalbild ist erforderlich.', points: 20, field: 'photos' })); + else if (photos.length < profile.recommendedPhotos) issues.push(issue({ code: 'few_photos', severity: 'warning', category: 'Bilder', title: 'Wenige Bilder', detail: `${profile.recommendedPhotos} oder mehr Perspektiven werden für dieses Profil empfohlen.`, points: clamp((profile.recommendedPhotos - photos.length) * 2, 2, 7), field: 'photos' })); + + if (!(Number(listing.price) > 0)) issues.push(issue({ code: 'missing_price', severity: 'error', category: 'Preis', title: 'Preis fehlt', detail: 'Ein positiver Verkaufspreis ist erforderlich.', points: 12, field: 'price' })); + if (!normalizeText(listing.condition)) issues.push(issue({ code: 'missing_condition', severity: 'error', category: 'Metadaten', title: 'Zustand fehlt', detail: 'Der Zustand ist für Käufer und Plattformformulare wichtig.', points: 8, field: 'condition' })); + if (!normalizeText(listing.category)) issues.push(issue({ code: 'missing_category', severity: 'error', category: 'Metadaten', title: 'Kategorie fehlt', detail: 'Eine passende Kategorie verbessert Auffindbarkeit und Publishing.', points: 8, field: 'category' })); + if (!normalizeText(listing.brand)) issues.push(issue({ code: 'missing_brand', severity: 'recommendation', category: 'Metadaten', title: 'Marke nicht angegeben', detail: 'Marke ergänzen, sofern sie am Produkt sicher erkennbar ist.', points: 3, field: 'brand' })); + if (!normalizeText(listing.color)) issues.push(issue({ code: 'missing_color', severity: 'recommendation', category: 'Metadaten', title: 'Farbe nicht angegeben', detail: 'Eine Farbangabe erleichtert Suche und Filterung.', points: 2, field: 'color' })); + if (!tags.length) issues.push(issue({ code: 'missing_tags', severity: 'recommendation', category: 'Reichweite', title: 'Keine Suchbegriffe', detail: 'Passende Tags oder Suchbegriffe können die Auffindbarkeit verbessern.', points: 3, field: 'tags', action: 'ai_tags' })); + if (!normalizeText(listing.sku)) issues.push(issue({ code: 'missing_sku', severity: 'warning', category: 'Organisation', title: 'SKU fehlt', detail: 'Eine eindeutige Artikelnummer erleichtert Lager und Publishing.', points: 4, field: 'sku' })); + if (!normalizeText(listing.storage_location)) issues.push(issue({ code: 'missing_storage', severity: 'recommendation', category: 'Organisation', title: 'Kein Lagerort', detail: 'Ein Lagerort verhindert Suchaufwand nach dem Verkauf.', points: 2, field: 'storage_location' })); + + const duplicates = findDuplicates(listing, allListings); + if (duplicates.some(item => item.same_sku)) issues.push(issue({ code: 'duplicate_sku', severity: 'error', category: 'Duplikate', title: 'Doppelte SKU möglich', detail: 'Mindestens ein anderer Artikel verwendet dieselbe SKU.', points: 10, field: 'sku' })); + else if (duplicates.length) issues.push(issue({ code: 'possible_duplicate', severity: 'warning', category: 'Duplikate', title: 'Ähnlicher Artikel gefunden', detail: 'Titel und Produktdaten ähneln mindestens einem bestehenden Artikel.', points: 5 })); + + const penalty = issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); + const score = clamp(100 - penalty, 0, 100); + const blockers = issues.filter(entry => entry.severity === 'error'); + return { + listing_id: listing.id, + score, + grade: gradeFor(score), + blockers: blockers.length, + warnings: issues.filter(entry => entry.severity === 'warning').length, + recommendations: issues.filter(entry => entry.severity === 'recommendation').length, + ready: blockers.length === 0 && score >= 70, + platform: { id: listing.platform || '', name: profile.name, profile }, + issues, + duplicates, + facts: { + title_length: title.length, + description_length: description.length, + photo_count: photos.length, + tag_count: tags.length, + }, + listing: { + id: listing.id, title: listing.title, sku: listing.sku, platform: listing.platform, status: listing.status, + price: listing.price, photo: photos[0] || null, updated_at: listing.updated_at, created_at: listing.created_at, + }, + }; +} + +async function inspectImages(report, listing, uploadRoot) { + const details = []; + for (const photo of (listing.photos || []).slice(0, 12)) { + const relative = String(photo || '').replace(/^[/\\]+/, ''); + const absolute = resolve(uploadRoot, relative); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) { + details.push({ path: relative, missing: true }); + report.issues.push(issue({ code: 'photo_missing_file', severity: 'error', category: 'Bilder', title: 'Bilddatei fehlt', detail: `Die Datei ${relative || 'unbekannt'} wurde nicht gefunden.`, points: 8, field: 'photos' })); + continue; + } + try { + const meta = await sharp(absolute).metadata(); + const width = Number(meta.width || 0); const height = Number(meta.height || 0); + const megapixels = Math.round((width * height / 1_000_000) * 10) / 10; + details.push({ path: relative, width, height, megapixels, format: meta.format || '' }); + if (Math.min(width, height) < 700) report.issues.push(issue({ code: 'photo_low_resolution', severity: 'warning', category: 'Bilder', title: 'Niedrige Bildauflösung', detail: `${relative}: ${width} × ${height} px.`, points: 3, field: 'photos' })); + if (width === height && width < 1000) report.issues.push(issue({ code: 'photo_small_square', severity: 'recommendation', category: 'Bilder', title: 'Kleines quadratisches Bild', detail: `${relative} ist quadratisch, aber relativ klein.`, points: 1, field: 'photos' })); + } catch { + details.push({ path: relative, unreadable: true }); + report.issues.push(issue({ code: 'photo_unreadable', severity: 'error', category: 'Bilder', title: 'Bild nicht lesbar', detail: `${relative} konnte nicht analysiert werden.`, points: 6, field: 'photos' })); + } + } + report.images = details; + const penalty = report.issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); + report.score = clamp(100 - penalty, 0, 100); + report.grade = gradeFor(report.score); + report.blockers = report.issues.filter(entry => entry.severity === 'error').length; + report.warnings = report.issues.filter(entry => entry.severity === 'warning').length; + report.recommendations = report.issues.filter(entry => entry.severity === 'recommendation').length; + report.ready = report.blockers === 0 && report.score >= 70; + return report; +} + +function persistReport(report) { + const result = db.prepare(`INSERT INTO quality_reports (listing_id, score, grade, ready, blockers, warnings, recommendations, report_json, analyzed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run( + report.listing_id, report.score, report.grade, report.ready ? 1 : 0, report.blockers, report.warnings, report.recommendations, JSON.stringify(report) + ); + return Number(result.lastInsertRowid); +} + +export function getLatestQualityReport(listingId) { + const row = db.prepare('SELECT * FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 1').get(listingId); + if (!row) return null; + const report = safeParseJson(row.report_json, null); + return report ? { ...report, report_id: row.id, analyzed_at: row.analyzed_at } : null; +} + +export async function analyzeListingQuality(listingId, { uploadRoot, persist = true } = {}) { + const listing = getListing(listingId); + if (!listing) return null; + const allListings = getListings(); + let report = inspectCore(listing, allListings); + if (uploadRoot) report = await inspectImages(report, listing, uploadRoot); + const fee = calculateFees(listing.platform, Number(listing.price) || 0); + report.financials = { price: Number(listing.price) || 0, fees: fee.fees || 0, estimated_net: fee.net || 0, note: fee.note || '', breakdown: fee.breakdown || '' }; + report.analyzed_at = new Date().toISOString(); + if (persist) report.report_id = persistReport(report); + return report; +} + +export function listQualityCenter({ query = '', status = 'all', grade = 'all', page = 1, pageSize = 10 } = {}) { + const allListings = getListings(); + const allReports = allListings.map(listing => { + const latest = getLatestQualityReport(listing.id); + const listingChangedAt = Date.parse(String(listing.updated_at || listing.created_at || '').replace(' ', 'T') + 'Z') || 0; + const analyzedAt = Date.parse(String(latest?.analyzed_at || '').replace(' ', 'T') + 'Z') || 0; + return latest && analyzedAt >= listingChangedAt ? latest : inspectCore(listing, allListings); + }); + let reports = [...allReports]; + const q = normalizeText(query).toLowerCase(); + if (q) reports = reports.filter(item => [item.listing?.title, item.listing?.sku, item.platform?.name].some(value => String(value || '').toLowerCase().includes(q))); + if (status === 'ready') reports = reports.filter(item => item.ready); + if (status === 'blocked') reports = reports.filter(item => item.blockers > 0); + if (status === 'review') reports = reports.filter(item => !item.ready && item.blockers === 0); + if (grade !== 'all') reports = reports.filter(item => item.grade === grade); + reports.sort((a, b) => a.score - b.score || String(b.listing?.updated_at || b.listing?.created_at || '').localeCompare(String(a.listing?.updated_at || a.listing?.created_at || ''))); + const safeSize = [5, 10, 15, 25].includes(Number(pageSize)) ? Number(pageSize) : 10; + const total = reports.length; + const totalPages = Math.max(1, Math.ceil(total / safeSize)); + const safePage = clamp(Number(page) || 1, 1, totalPages); + const items = reports.slice((safePage - 1) * safeSize, safePage * safeSize); + const summary = { + total: allReports.length, + ready: allReports.filter(report => report.ready).length, + blocked: allReports.filter(report => report.blockers > 0).length, + average_score: allReports.length ? Math.round(allReports.reduce((sum, report) => sum + report.score, 0) / allReports.length) : 0, + }; + return { items, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary }; +} + +export function getQualityHistory(listingId, limit = 12) { + return db.prepare('SELECT id, score, grade, ready, blockers, warnings, recommendations, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT ?').all(listingId, Math.min(Math.max(Number(limit) || 12, 1), 50)); +} + +export function compareQualityReports(listingId) { + const rows = db.prepare('SELECT id, score, grade, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 2').all(listingId); + return { latest: rows[0] || null, previous: rows[1] || null, delta: rows.length > 1 ? rows[0].score - rows[1].score : 0 }; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/runtime-paths.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/runtime-paths.mjs new file mode 100644 index 0000000..3ca969e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/runtime-paths.mjs @@ -0,0 +1,60 @@ +import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { dirname, isAbsolute, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID } from 'crypto'; + +export const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function envPath(name, fallback) { + const value = String(process.env[name] || '').trim(); + if (!value) return resolve(fallback); + return resolve(isAbsolute(value) ? value : join(APP_ROOT, value)); +} + +export const DATA_ROOT = envPath('VENDOO_DATA_DIR', APP_ROOT); +export const DB_PATH = envPath('VENDOO_DB_PATH', join(DATA_ROOT, 'db', 'vendoo.db')); +export const UPLOADS_DIR = envPath('VENDOO_UPLOADS_DIR', join(DATA_ROOT, 'uploads')); +export const BACKUP_DIR = envPath('VENDOO_BACKUP_DIR', join(DATA_ROOT, 'backups')); +export const OPERATIONS_DIR = envPath('VENDOO_OPERATIONS_DIR', join(DATA_ROOT, 'operations')); +export const LOG_DIR = envPath('VENDOO_LOG_DIR', join(DATA_ROOT, 'logs')); +export const CONFIG_PATH = envPath('VENDOO_CONFIG_PATH', join(DATA_ROOT, 'config', 'vendoo.env')); + +export const APP_VERSION = JSON.parse(readFileSync(join(APP_ROOT, 'package.json'), 'utf8')).version; + +export function ensureRuntimeDirectories() { + const directories = [dirname(DB_PATH), UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, LOG_DIR, dirname(CONFIG_PATH)]; + for (const directory of directories) mkdirSync(directory, { recursive: true }); + return directories; +} + +export function verifyRuntimeWritable() { + ensureRuntimeDirectories(); + const checks = []; + for (const directory of [dirname(DB_PATH), UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, LOG_DIR, dirname(CONFIG_PATH)]) { + try { + accessSync(directory, constants.R_OK | constants.W_OK); + const probe = join(directory, `.vendoo-write-${process.pid}-${randomUUID()}.tmp`); + writeFileSync(probe, 'ok', { flag: 'wx' }); + unlinkSync(probe); + checks.push({ path: directory, writable: true }); + } catch (error) { + checks.push({ path: directory, writable: false, error: error.message }); + } + } + return checks; +} + +export function runtimePathSummary() { + return { + app_root: APP_ROOT, + data_root: DATA_ROOT, + database: DB_PATH, + uploads: UPLOADS_DIR, + backups: BACKUP_DIR, + operations: OPERATIONS_DIR, + logs: LOG_DIR, + runtime_config: CONFIG_PATH, + }; +} + +ensureRuntimeDirectories(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/security.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/security.mjs new file mode 100644 index 0000000..485eb5c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/security.mjs @@ -0,0 +1,329 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; +import { db } from './db.mjs'; + +export const PERMISSION_CATALOG = Object.freeze([ + { key: 'dashboard.view', label: 'Dashboard ansehen', group: 'Allgemein' }, + { key: 'listings.view', label: 'Artikel ansehen', group: 'Artikel' }, + { key: 'listings.edit', label: 'Artikel erstellen und bearbeiten', group: 'Artikel' }, + { key: 'listings.delete', label: 'Artikel in den Papierkorb verschieben', group: 'Artikel' }, + { key: 'listings.permanent_delete', label: 'Artikel endgültig löschen', group: 'Artikel' }, + { key: 'media.view', label: 'Bilder ansehen und herunterladen', group: 'Bilder' }, + { key: 'media.edit', label: 'Bilder hochladen und bearbeiten', group: 'Bilder' }, + { key: 'media.delete', label: 'Bilder löschen', group: 'Bilder' }, + { key: 'generation.execute', label: 'AI- und FLUX-Generierung starten', group: 'AI & FLUX' }, + { key: 'batch.execute', label: 'Bildproduktion ausführen', group: 'AI & FLUX' }, + { key: 'quality.execute', label: 'Qualitätsanalysen und AI-Verbesserungen', group: 'Qualität' }, + { key: 'publishing.view', label: 'Publishing ansehen', group: 'Publishing' }, + { key: 'publishing.execute', label: 'Veröffentlichungen starten und wiederholen', group: 'Publishing' }, + { key: 'publishing.manage', label: 'Veröffentlichungen beenden und endgültig löschen', group: 'Publishing' }, + { key: 'inventory.edit', label: 'Lagerorte und Bestände ändern', group: 'Organisation' }, + { key: 'templates.edit', label: 'Vorlagen verwalten', group: 'Organisation' }, + { key: 'settings.manage', label: 'Globale Einstellungen ändern', group: 'Administration' }, + { key: 'users.manage', label: 'Benutzer und Rollen verwalten', group: 'Administration' }, + { key: 'sessions.manage', label: 'Fremde Sitzungen beenden', group: 'Administration' }, + { key: 'audit.view', label: 'Audit-Protokoll ansehen und exportieren', group: 'Administration' }, + { key: 'operations.manage', label: 'Backups, Restore und Updates verwalten', group: 'Administration' }, + { key: 'security.manage', label: 'Server-Sicherheit konfigurieren', group: 'Administration' }, +]); + +const ALL_PERMISSIONS = PERMISSION_CATALOG.map(item => item.key); + +export const ROLE_DEFINITIONS = Object.freeze({ + admin: { + key: 'admin', + label: 'Administrator', + description: 'Vollzugriff auf Vendoo, Benutzer, Backups, Updates und Server-Sicherheit.', + permissions: ALL_PERMISSIONS, + }, + manager: { + key: 'manager', + label: 'Manager', + description: 'Operative Leitung mit vollständiger Artikel-, Medien- und Publishing-Verwaltung, ohne System- und Benutzeradministration.', + permissions: ALL_PERMISSIONS.filter(key => ![ + 'users.manage', 'sessions.manage', 'operations.manage', 'security.manage', 'settings.manage', + ].includes(key)), + }, + editor: { + key: 'editor', + label: 'Editor', + description: 'Erstellt und bearbeitet Artikel, Bilder und AI-Inhalte. Keine endgültigen Löschungen oder Systemverwaltung.', + permissions: [ + 'dashboard.view', 'listings.view', 'listings.edit', 'listings.delete', + 'media.view', 'media.edit', 'generation.execute', 'batch.execute', 'quality.execute', + 'publishing.view', 'publishing.execute', 'inventory.edit', 'templates.edit', + ], + }, + publisher: { + key: 'publisher', + label: 'Publisher', + description: 'Prüft Artikel und führt Veröffentlichungen aus, ohne Artikelstammdaten oder Systemeinstellungen zu ändern.', + permissions: [ + 'dashboard.view', 'listings.view', 'media.view', 'quality.execute', + 'publishing.view', 'publishing.execute', + ], + }, + viewer: { + key: 'viewer', + label: 'Leser', + description: 'Reiner Lesezugriff auf Artikel, Bilder, Qualität und Publishing-Status.', + permissions: ['dashboard.view', 'listings.view', 'media.view', 'publishing.view'], + }, +}); + +const MUTATION_DEFAULT_PERMISSION = 'listings.edit'; + +export function normalizeRole(role) { + return ROLE_DEFINITIONS[role] ? role : 'viewer'; +} + +export function getRoleDefinitions() { + return Object.values(ROLE_DEFINITIONS).map(role => ({ ...role, permissions: [...role.permissions] })); +} + +export function getPermissionCatalog() { + return PERMISSION_CATALOG.map(item => ({ ...item })); +} + +export function permissionsForRole(role) { + return new Set(ROLE_DEFINITIONS[normalizeRole(role)].permissions); +} + +export function hasPermission(role, permission) { + if (!permission) return true; + return permissionsForRole(role).has(permission); +} + +export function requiredPermissionForApi(method, path) { + const verb = String(method || 'GET').toUpperCase(); + const route = String(path || '').split('?')[0]; + + if (route === '/api/me' || route.startsWith('/api/my/')) return null; + if (route === '/api/extensions/heartbeat' || route === '/api/extensions/prepare') return null; + + if (route === '/api/backup' || route === '/api/restore') return 'operations.manage'; + if (route.startsWith('/api/admin/users')) return 'users.manage'; + if (route.startsWith('/api/admin/sessions')) return verb === 'GET' ? 'sessions.manage' : 'sessions.manage'; + if (route.startsWith('/api/admin/audit')) return 'audit.view'; + if (route.startsWith('/api/admin/') || route.startsWith('/api/operations/')) return 'operations.manage'; + if (route.startsWith('/api/settings')) return verb === 'GET' ? null : 'settings.manage'; + if (route.startsWith('/api/themes')) return null; // sichere Detail-Policies übernimmt der Platform Kernel + if (route.startsWith('/api/extensions/diagnostics')) return 'security.manage'; + if (route.startsWith('/api/security/')) return verb === 'GET' ? 'security.manage' : 'security.manage'; + if (route.startsWith('/api/locks/admin/')) return 'sessions.manage'; + + if (route.startsWith('/api/trash')) { + if (verb === 'GET') return 'listings.view'; + if (verb === 'DELETE') return 'listings.permanent_delete'; + return 'listings.edit'; + } + if (route.startsWith('/api/listings')) { + if (verb === 'GET') return 'listings.view'; + if (verb === 'DELETE') return 'listings.delete'; + return 'listings.edit'; + } + if (route.startsWith('/api/templates')) return verb === 'GET' ? null : 'templates.edit'; + if (route.startsWith('/api/storage') || route.startsWith('/api/inventory')) return verb === 'GET' ? null : 'inventory.edit'; + + if (route.startsWith('/api/media') || route.startsWith('/api/gallery')) { + if (verb === 'GET') return 'media.view'; + if (verb === 'DELETE') return 'media.delete'; + return 'media.edit'; + } + if (route.startsWith('/api/upload') || route.startsWith('/api/images') || route.startsWith('/api/image-editor') || route.startsWith('/api/mobile-upload-sessions')) { + if (verb === 'GET') return 'media.view'; + if (verb === 'DELETE') return 'media.delete'; + return 'media.edit'; + } + if (route === '/api/html-render' || route === '/api/html-wrap') return 'listings.view'; + if (route === '/api/photos/archive') return 'publishing.execute'; + if (route.startsWith('/api/local-image/install') || route.startsWith('/api/local-image/update') || route.startsWith('/api/local-image/uninstall') || route.startsWith('/api/local-image/regenerate-token')) return 'settings.manage'; + if (route.startsWith('/api/ebay')) { + if (verb === 'GET') return 'publishing.view'; + if (verb === 'DELETE') return 'publishing.manage'; + return 'publishing.execute'; + } + if (route.startsWith('/api/flux') || route.startsWith('/api/local-image') || route.startsWith('/api/ai-model') || route === '/api/generate' || route.startsWith('/api/prompt-lab')) { + return verb === 'GET' ? 'media.view' : 'generation.execute'; + } + if (route.startsWith('/api/image-batch')) return verb === 'GET' ? 'media.view' : 'batch.execute'; + if (route.startsWith('/api/quality')) return verb === 'GET' ? 'listings.view' : 'quality.execute'; + if (route.startsWith('/api/publishing') || route.startsWith('/api/extension/publishing') || route.startsWith('/api/publish') || route.startsWith('/api/schedule')) { + if (verb === 'GET') return 'publishing.view'; + if (verb === 'DELETE' || route.includes('/end') || route.includes('/delete')) return 'publishing.manage'; + return 'publishing.execute'; + } + if (route.startsWith('/api/extension/queue') || route.startsWith('/api/extension/listing')) return 'publishing.view'; + + if (['GET', 'HEAD', 'OPTIONS'].includes(verb)) return null; + return MUTATION_DEFAULT_PERMISSION; +} + +// --- Resource locks ------------------------------------------------------- + +db.exec(` + CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + acquired_at TEXT NOT NULL DEFAULT (datetime('now')), + renewed_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + client_label TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(resource_type, resource_id) + ); + CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at); + CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at); +`); + +try { + db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) + VALUES ('multiuser-security-1.31.0', '1.31.0', 'Rollen, sichere Sitzungen, Bearbeitungssperren und Server-Härtung')`).run(); +} catch {} + +function hashLockToken(token) { + return createHash('sha256').update(String(token || '')).digest('hex'); +} + +function safeEqualHex(a, b) { + try { + const aa = Buffer.from(String(a || ''), 'hex'); + const bb = Buffer.from(String(b || ''), 'hex'); + return aa.length > 0 && aa.length === bb.length && timingSafeEqual(aa, bb); + } catch { return false; } +} + +export function cleanExpiredResourceLocks() { + return db.prepare("DELETE FROM resource_locks WHERE expires_at <= datetime('now')").run().changes; +} + +function hydrateLock(row) { + if (!row) return null; + return { + id: row.id, + resource_type: row.resource_type, + resource_id: row.resource_id, + user_id: row.user_id, + user_name: row.user_name || '', + user_email: row.user_email || '', + acquired_at: row.acquired_at, + renewed_at: row.renewed_at, + expires_at: row.expires_at, + client_label: row.client_label || '', + }; +} + +export function getResourceLock(resourceType, resourceId) { + cleanExpiredResourceLocks(); + const row = db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email + FROM resource_locks l JOIN users u ON u.id = l.user_id + WHERE l.resource_type = ? AND l.resource_id = ? AND l.expires_at > datetime('now')`).get(String(resourceType), String(resourceId)); + return hydrateLock(row); +} + +export function listResourceLocks() { + cleanExpiredResourceLocks(); + return db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email + FROM resource_locks l JOIN users u ON u.id = l.user_id + WHERE l.expires_at > datetime('now') ORDER BY l.renewed_at DESC`).all().map(hydrateLock); +} + +export function acquireResourceLock({ resourceType, resourceId, userId, clientLabel = '', ttlSeconds = 120 }) { + cleanExpiredResourceLocks(); + const type = String(resourceType || '').trim().slice(0, 40); + const id = String(resourceId || '').trim().slice(0, 120); + if (!type || !id) throw new Error('Ressourcentyp und ID sind erforderlich'); + const existing = getResourceLock(type, id); + if (existing && Number(existing.user_id) !== Number(userId)) { + const error = new Error(`${existing.user_name || existing.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Datensatz bereits.`); + error.code = 'LOCKED'; + error.lock = existing; + throw error; + } + + const token = randomBytes(32).toString('hex'); + const tokenHash = hashLockToken(token); + const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); + const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); + const transaction = db.transaction(() => { + db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id); + db.prepare(`INSERT INTO resource_locks + (resource_type, resource_id, user_id, token_hash, expires_at, client_label) + VALUES (?, ?, ?, ?, ?, ?)`).run(type, id, userId, tokenHash, expiresAt, String(clientLabel || '').slice(0, 160)); + }); + transaction(); + return { token, lock: getResourceLock(type, id) }; +} + +export function validateResourceLock({ resourceType, resourceId, userId, token }) { + const type = String(resourceType || ''); + const id = String(resourceId || ''); + const row = db.prepare(`SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND expires_at > datetime('now')`).get(type, id); + if (!row) return { valid: true, lock: null }; + const providedHash = hashLockToken(token); + const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, providedHash); + return { valid, lock: getResourceLock(type, id) }; +} + +export function renewResourceLock({ resourceType, resourceId, userId, token, ttlSeconds = 120 }) { + const validation = validateResourceLock({ resourceType, resourceId, userId, token }); + if (!validation.valid || !validation.lock) { + const error = new Error('Bearbeitungssperre ist abgelaufen oder gehört zu einer anderen Sitzung.'); + error.code = 'LOCK_INVALID'; + error.lock = validation.lock; + throw error; + } + const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); + const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); + db.prepare(`UPDATE resource_locks SET renewed_at = datetime('now'), expires_at = ? + WHERE resource_type = ? AND resource_id = ?`).run(expiresAt, String(resourceType), String(resourceId)); + return getResourceLock(resourceType, resourceId); +} + +export function releaseResourceLock({ resourceType, resourceId, userId, token, force = false }) { + const type = String(resourceType || ''); + const id = String(resourceId || ''); + if (force) return db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id).changes; + const row = db.prepare('SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ?').get(type, id); + if (!row) return 0; + const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, hashLockToken(token)); + if (!valid) { + const error = new Error('Bearbeitungssperre kann nicht durch diese Sitzung aufgehoben werden.'); + error.code = 'LOCK_INVALID'; + throw error; + } + return db.prepare('DELETE FROM resource_locks WHERE id = ?').run(row.id).changes; +} + +// --- Rate limiter --------------------------------------------------------- +const rateBuckets = new Map(); +const rateStats = { allowed: 0, blocked: 0, buckets: 0, last_blocked_at: null }; + +export function consumeRateLimit(key, { limit = 120, windowMs = 60_000 } = {}) { + const now = Date.now(); + const normalizedKey = String(key || 'anonymous'); + let bucket = rateBuckets.get(normalizedKey); + if (!bucket || now >= bucket.resetAt) bucket = { count: 0, resetAt: now + windowMs }; + bucket.count += 1; + rateBuckets.set(normalizedKey, bucket); + rateStats.buckets = rateBuckets.size; + const remaining = Math.max(0, limit - bucket.count); + const allowed = bucket.count <= limit; + if (allowed) rateStats.allowed += 1; + else { + rateStats.blocked += 1; + rateStats.last_blocked_at = new Date().toISOString(); + } + if (rateBuckets.size > 5000) { + for (const [entryKey, entry] of rateBuckets) if (entry.resetAt <= now) rateBuckets.delete(entryKey); + } + return { allowed, remaining, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), resetAt: bucket.resetAt }; +} + +export function getRateLimitStats() { + return { ...rateStats, buckets: rateBuckets.size }; +} + +setInterval(cleanExpiredResourceLocks, 60_000).unref?.(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/zip.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/zip.mjs new file mode 100644 index 0000000..e4e84b6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/lib/zip.mjs @@ -0,0 +1,89 @@ +import { deflateRawSync } from 'zlib'; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = date.getDate(); + const month = date.getMonth() + 1; + const dosDate = ((year - 1980) << 9) | (month << 5) | day; + return { time, date: dosDate }; +} + +export function createZip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + const stamp = dosDateTime(); + + for (const file of files) { + const name = Buffer.from(String(file.name || 'file.bin').replace(/\\/g, '/'), 'utf8'); + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data || ''); + const compressed = deflateRawSync(data, { level: 6 }); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(stamp.time, 10); + local.writeUInt16LE(stamp.date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(stamp.time, 12); + central.writeUInt16LE(stamp.date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + + offset += local.length + name.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDirectory, end]); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/start-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/start-local-flux.ps1 new file mode 100644 index 0000000..4e44413 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/start-local-flux.ps1 @@ -0,0 +1,210 @@ +param( + [int]$StartupTimeoutSeconds = 180, + [ValidateSet('auto','fast','balanced','lowvram')] + [string]$Profile = 'auto' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if ((Split-Path -Leaf $ScriptDir) -eq 'local-ai') { + $VendooDir = Split-Path -Parent $ScriptDir +} else { + $VendooDir = Split-Path -Parent $ScriptDir +} +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$PortableRoot = Join-Path $LocalAiDir 'comfyui\ComfyUI_windows_portable' +$Python = Join-Path $PortableRoot 'python_embeded\python.exe' +$Main = Join-Path $PortableRoot 'ComfyUI\main.py' +$LogDir = Join-Path $VendooDir 'logs' +$StdoutLog = Join-Path $LogDir 'local-flux-runtime.log' +$StderrLog = Join-Path $LogDir 'local-flux-runtime-error.log' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' +$ComfyUrl = 'http://127.0.0.1:8188' +$ComfyPort = 8188 + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$env:NO_COLOR = '1' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Remove-Ansi([string]$Text) { + if ([string]::IsNullOrEmpty($Text)) { return '' } + $pattern = ([string][char]27) + '\\[[0-?]*[ -/]*[@-~]' + return [regex]::Replace($Text, $pattern, '') +} + +function Read-Tail([string]$Path, [int]$Count = 16) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + try { + return @(Get-Content -LiteralPath $Path -Tail $Count -ErrorAction SilentlyContinue | ForEach-Object { Remove-Ansi ([string]$_) }) + } catch { return @() } +} + +function Save-RuntimeState([string]$Status, [string]$Message, [int]$ProcessId = 0, [int]$Elapsed = 0, [string]$ErrorMessage = '') { + $state = [ordered]@{ + status = $Status + message = $Message + pid = if ($ProcessId -gt 0) { $ProcessId } else { $null } + port = $ComfyPort + url = $ComfyUrl + elapsed_seconds = $Elapsed + updated_at = (Get-Date).ToString('o') + stdout_log = $StdoutLog + stderr_log = $StderrLog + error = $ErrorMessage + stdout_tail = @(Read-Tail $StdoutLog 12) + stderr_tail = @(Read-Tail $StderrLog 16) + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 5) + if ($ProcessId -gt 0) { Write-Utf8NoBom -Path $PidFile -Content ([string]$ProcessId) } +} + + +function Test-ComfyFlag([string]$Flag) { + try { + $help = & $Python -s $Main --help 2>&1 | Out-String + return $help -match [regex]::Escape($Flag) + } catch { return $false } +} + +function Get-NvidiaVramGb { + try { + $line = & nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1 + if ($line -match '^\s*(\d+)') { return [math]::Round(([double]$Matches[1] / 1024), 1) } + } catch {} + return 0 +} + +function Get-PerformanceArguments([string]$RequestedProfile) { + $effective = $RequestedProfile + $vramGb = Get-NvidiaVramGb + if ($effective -eq 'auto') { + if ($vramGb -ge 12) { $effective = 'fast' } + elseif ($vramGb -gt 0 -and $vramGb -lt 8) { $effective = 'lowvram' } + else { $effective = 'balanced' } + } + $flags = New-Object System.Collections.Generic.List[string] + if ($effective -eq 'fast') { + $flags.Add('--highvram') + $flags.Add('--cache-classic') + if (Test-ComfyFlag '--fast') { + $flags.Add('--fast'); $flags.Add('fp16_accumulation'); $flags.Add('cublas_ops') + } + } elseif ($effective -eq 'lowvram') { + $flags.Add('--lowvram') + $flags.Add('--cache-none') + } else { + $flags.Add('--cache-classic') + } + return [pscustomobject]@{ profile = $effective; requested = $RequestedProfile; vram_gb = $vramGb; flags = @($flags) } +} + +function Test-ComfyReady { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 4 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +function Get-ComfyProcess { + try { + $items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and + $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and + $_.CommandLine -match "--port\s+$ComfyPort" + } | Select-Object -First 1) + if ($items.Count -gt 0) { return $items[0] } + return $null + } catch { return $null } +} + +try { + if (-not (Test-Path -LiteralPath $Python)) { throw "Embedded Python wurde nicht gefunden: $Python" } + if (-not (Test-Path -LiteralPath $Main)) { throw "ComfyUI main.py wurde nicht gefunden: $Main" } + + if (Test-ComfyReady) { + $existingReady = Get-ComfyProcess + $readyPid = if ($existingReady) { [int]$existingReady.ProcessId } else { 0 } + Save-RuntimeState 'ready' 'FLUX / ComfyUI ist bereits aktiv und erreichbar.' $readyPid 0 + exit 0 + } + + $existing = Get-ComfyProcess + $comfyPid = 0 + $process = $null + if ($existing) { + $comfyPid = [int]$existing.ProcessId + Save-RuntimeState 'starting' 'Ein vorhandener ComfyUI-Prozess initialisiert noch.' $comfyPid 0 + } else { + Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue + $performance = Get-PerformanceArguments $Profile + $arguments = @( + '-s', $Main, + '--listen', '127.0.0.1', + '--port', [string]$ComfyPort, + '--disable-auto-launch', + '--preview-method', 'none', + '--windows-standalone-build', + '--disable-all-custom-nodes', + '--log-stdout', + '--verbose', 'INFO' + ) + foreach ($flag in $performance.flags) { $arguments += $flag } + $process = Start-Process -FilePath $Python -ArgumentList $arguments -WorkingDirectory $PortableRoot -WindowStyle Hidden -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -PassThru + if (-not $process) { throw 'Der ComfyUI-Prozess konnte nicht gestartet werden.' } + $comfyPid = [int]$process.Id + Save-RuntimeState 'starting' ("FLUX / ComfyUI wird initialisiert · Profil: {0} · VRAM: {1} GB." -f $performance.profile, $performance.vram_gb) $comfyPid 0 + } + + $startedAt = Get-Date + while (((Get-Date) - $startedAt).TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Seconds 2 + $elapsed = [int][math]::Round(((Get-Date) - $startedAt).TotalSeconds) + + if (Test-ComfyReady) { + Save-RuntimeState 'ready' "FLUX / ComfyUI ist nach ${elapsed}s erreichbar." $comfyPid $elapsed + exit 0 + } + + $alive = $false + try { $alive = $null -ne (Get-Process -Id $comfyPid -ErrorAction SilentlyContinue) } catch {} + if (-not $alive) { + $stderrTail = (Read-Tail $StderrLog 24) -join "`n" + $stdoutTail = (Read-Tail $StdoutLog 24) -join "`n" + $detail = if ($stderrTail) { $stderrTail } elseif ($stdoutTail) { $stdoutTail } else { 'Der Prozess wurde ohne verwertbare Ausgabe beendet.' } + Save-RuntimeState 'failed' 'ComfyUI wurde während des Starts beendet.' $comfyPid $elapsed $detail + throw "ComfyUI wurde während des Starts beendet. $detail" + } + + $tail = @((Read-Tail $StdoutLog 3) + (Read-Tail $StderrLog 3)) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $lastLine = if ($tail.Count -gt 0) { [string]$tail[-1] } else { 'ComfyUI initialisiert Python, Datenbank und Modelle.' } + Save-RuntimeState 'starting' "Start läuft seit ${elapsed}s: $lastLine" $comfyPid $elapsed + } + + $timeoutDetail = @((Read-Tail $StderrLog 24) + (Read-Tail $StdoutLog 24)) -join "`n" + Stop-Process -Id $comfyPid -Force -ErrorAction SilentlyContinue + Save-RuntimeState 'failed' "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet." $comfyPid $StartupTimeoutSeconds $timeoutDetail + throw "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet. Prüfe $StderrLog und $StdoutLog." +} catch { + $message = Remove-Ansi ([string]$_.Exception.Message) + $currentPid = 0 + try { if (Test-Path -LiteralPath $PidFile) { $currentPid = [int](Get-Content -LiteralPath $PidFile -Raw).Trim() } } catch {} + $elapsed = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $oldState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $elapsed = [int]$oldState.elapsed_seconds + } + } catch {} + Save-RuntimeState 'failed' 'FLUX / ComfyUI konnte nicht gestartet werden.' $currentPid $elapsed $message + Write-Error $message + exit 1 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/stop-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/stop-local-flux.ps1 new file mode 100644 index 0000000..73ffc6a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/stop-local-flux.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'SilentlyContinue' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LogDir = Join-Path $VendooDir 'logs' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +$items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and $_.CommandLine -match '--port\s+8188' +}) +foreach ($item in $items) { Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue } +Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$state = [ordered]@{ + status = 'stopped' + message = 'FLUX / ComfyUI wurde beendet.' + pid = $null + port = 8188 + url = 'http://127.0.0.1:8188' + elapsed_seconds = 0 + updated_at = (Get-Date).ToString('o') + error = '' +} +Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) +exit 0 diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/workflows/vendoo_flux_schnell_api.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/workflows/vendoo_flux_schnell_api.json new file mode 100644 index 0000000..38794bc --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/local-ai/workflows/vendoo_flux_schnell_api.json @@ -0,0 +1,86 @@ +{ + "3": { + "inputs": { + "ckpt_name": "flux1-schnell-fp8.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "text": "__PROMPT__", + "clip": [ + "3", + 1 + ] + }, + "class_type": "CLIPTextEncode" + }, + "5": { + "inputs": { + "text": "__NEGATIVE_PROMPT__", + "clip": [ + "3", + 1 + ] + }, + "class_type": "CLIPTextEncode" + }, + "6": { + "inputs": { + "width": 1024, + "height": 1024, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage" + }, + "7": { + "inputs": { + "seed": "__SEED__", + "steps": 4, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1, + "model": [ + "3", + 0 + ], + "positive": [ + "4", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "6", + 0 + ] + }, + "class_type": "KSampler" + }, + "8": { + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "9": { + "inputs": { + "filename_prefix": "Vendoo/flux_schnell", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package-lock.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package-lock.json new file mode 100644 index 0000000..568d33f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package-lock.json @@ -0,0 +1,2140 @@ +{ + "name": "vendoo", + "version": "1.36.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vendoo", + "version": "1.36.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.11.1", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package.json new file mode 100644 index 0000000..e7c5561 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/package.json @@ -0,0 +1,40 @@ +{ + "name": "vendoo", + "version": "1.36.0", + "type": "module", + "private": true, + "scripts": { + "start": "node bootstrap.mjs", + "dev": "node --watch bootstrap.mjs", + "check": "node --check bootstrap.mjs && node --check server.mjs && node --check lib/config-store.mjs && node --check lib/runtime-paths.mjs && node --check lib/db.mjs && node --check lib/auth.mjs && node --check lib/security.mjs && node --check lib/operations-center.mjs && node --check lib/images.mjs && node --check lib/ai-local-images.mjs && node --check lib/ai-model-photos.mjs && node --check lib/ai-flux-prompt-jobs.mjs && node --check lib/image-editor.mjs && node --check lib/image-batch-jobs.mjs && node --check lib/quality-center.mjs && node --check public/app.js && node --check public/design-system/theme-runtime.js && node --check public/core/safe-dom.js && node --check public/core/frontend-module-registry.js && node --check public/core/api-client.js && node --check app/kernel/errors.mjs && node --check app/kernel/event-bus.mjs && node --check app/kernel/feature-flags.mjs && node --check app/kernel/module-contract.mjs && node --check app/kernel/module-registry.mjs && node --check app/kernel/policy-engine.mjs && node --check app/kernel/route-registry.mjs && node --check app/kernel/lifecycle.mjs && node --check app/kernel/platform-kernel.mjs && node --check app/modules/catalog.mjs && node --check app/core/themes/theme-contract.mjs && node --check tools/generate-design-tokens.mjs && node --check tools/verify-platform-architecture-1.35.0.mjs && node --check tools/verify-config-store.mjs && node --check tools/verify-git-safety.mjs && node --check tools/verify-deployment-1.35.0.mjs && node --check tools/verify-git-safety-regression.mjs && node --check tools/prepare-git-staging.mjs && node --check tools/verify-git-staging.mjs && node --check app/modules/themes/index.mjs && node --check app/modules/themes/service.mjs && node --check app/modules/themes/repository.mjs && node --check public/modules/theme-manager/theme-manager.js && node --check tools/verify-theme-manager-1.36.0.mjs && node --check tools/verify-platform-architecture-1.36.0.mjs && node --check tools/verify-deployment-1.36.0.mjs", + "verify:slice30": "node tools/verify-slice30-mock.mjs", + "verify:db": "python tools/verify-db-migrations.py", + "verify:slice31": "node tools/verify-slice31-image-editor.mjs", + "verify:hotfix": "node tools/verify-release-1.25.1.mjs", + "verify:gallery": "node tools/verify-release-1.25.1-static.mjs", + "verify:release": "node tools/verify-deployment-1.36.0.mjs", + "verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs", + "verify:deployment": "node tools/verify-deployment-1.36.0.mjs", + "verify:git": "node tools/verify-git-safety.mjs", + "verify:config": "node tools/verify-config-store.mjs", + "verify:installer-shell": "sh tools/verify-installer-shell.sh", + "verify:git-regression": "node tools/verify-git-safety-regression.mjs", + "verify:git-staging": "node tools/verify-git-staging.mjs", + "generate:tokens": "node tools/generate-design-tokens.mjs", + "verify:architecture": "node tools/verify-platform-architecture-1.36.0.mjs", + "verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run check && npm run verify:architecture && npm run verify:themes && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db", + "verify:themes": "node tools/verify-theme-manager-1.36.0.mjs" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.11.1", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-de.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-de.mjs new file mode 100644 index 0000000..7895862 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-de.mjs @@ -0,0 +1,45 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'eBay.de', + id: 'ebay-de', + fields: ['title', 'description', 'tags'], + maxTitleLength: 80, + maxTags: 0, + feePercent: 11, + publishUrl: 'https://www.ebay.de/sell/create', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay.de-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 80 Zeichen). Marke, Artikelart, Größe, Farbe, Zustand. Nutze gängige eBay-Suchbegriffe. +- **description**: Professionelle Artikelbeschreibung (5-8 Sätze). Material, Zustand detailliert, Maße, Besonderheiten, Versandhinweise. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Smartphone") +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Neu mit Etikett / Wie neu / Gut / Akzeptabel / Für Bastler) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und aktuelle eBay-Marktpreise + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-ka.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-ka.mjs new file mode 100644 index 0000000..e9da28e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/ebay-ka.mjs @@ -0,0 +1,39 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', +}; + +export default { + name: 'eBay Kleinanzeigen', + id: 'ebay-ka', + fields: ['title', 'description', 'tags'], + maxTitleLength: 65, + maxTags: 0, + feePercent: 0, + shippingNote: 'eBay KA erhebt keine Verkaufsgebühren. Versandkosten variieren.', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay-Kleinanzeigen-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten einer Anzeige' : 'eine Anzeige'}. + +Für jede Variante liefere: +- **title**: Prägnanter Titel (max. 65 Zeichen). Marke, Artikelart, Größe/Maße, Zustand. +- **description**: Ausführliche Beschreibung (5-8 Sätze). Zustand detailliert, Material, Maße, Abholung/Versand. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Wie neu / Gut / Akzeptabel / Defekt) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/etsy.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/etsy.mjs new file mode 100644 index 0000000..9b317ec --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/etsy.mjs @@ -0,0 +1,34 @@ +export default { + name: 'Etsy', + id: 'etsy', + fields: ['title', 'description', 'tags'], + maxTitleLength: 140, + maxTags: 13, + feePercent: 6.5, + listingFee: 0.20, + + buildPrompt(sellerNotes, { language = 'en', variants = 1 } = {}) { + const langNote = language === 'en' ? 'Write in English.' : language === 'de' ? 'Schreibe auf Deutsch.' : 'Write in English.'; + return `Du bist ein erfahrener Etsy-Verkäufer. ${langNote} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 140 Zeichen). Keywords, Marke, Material, Stil. +- **description**: Ansprechende Beschreibung (4-6 Sätze). Material, Besonderheiten, Maße, Zustand. Freundlicher Ton. +- **tags**: 13 relevante Tags (kurze Phrasen, OHNE #). + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unknown"/"Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (New / Like new / Good / Fair) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (einbauen, ggf. übersetzen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":["..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":["..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/vinted.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/vinted.mjs new file mode 100644 index 0000000..d0cbd2f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/platforms/vinted.mjs @@ -0,0 +1,44 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'Vinted', + id: 'vinted', + fields: ['title', 'description', 'hashtags'], + maxTitleLength: 100, + maxTags: 5, + feePercent: 5, + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener Vinted-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 100 Zeichen). Nenne Marke, Größe, Farbe, Artikelart. +- **description**: Verkaufsfördernde Beschreibung (3-5 Sätze). Material, Zustand, Besonderheiten. +- **hashtags**: Genau 5 relevante Hashtags mit # (z.B. #vintage #nike #sommer) + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Kleid") +- **size**: Größe falls erkennbar (z.B. "M", "42", "38") +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu mit Etikett / Neu ohne Etikett / Sehr gut / Gut / Befriedigend) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und Typ + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","hashtags":["#..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","hashtags":["#..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-192x192.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-192x192.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-512x512.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/android-chrome-512x512.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/app.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/app.js new file mode 100644 index 0000000..10a83d7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/app.js @@ -0,0 +1,9668 @@ +const VENDOO_UI_BUILD = '1.36.0'; + +const state = { + photos: [], + platform: '', + aiProvider: 'claude', + aiModel: '', + language: 'de', + variants: 1, + currentListing: null, + allVariants: null, + platforms: [], + providers: [], + templates: [], + editingTemplateId: null, + historyPage: 1, + historyPageSize: 10, + publishPage: 1, + publishPageSize: 10, + aiModelPhotos: [], + aiModelGenerations: [], + aiPromptScope: 'generator', + aiPromptDrafts: { + generator: { customPrompt: '', preserveLogos: true }, + detail: { customPrompt: '', preserveLogos: true }, + }, + fluxStudioCurrent: null, + fluxStudioHistory: [], + fluxHistoryPage: 1, + fluxHistoryPageSize: 5, + fluxHistoryPagination: { page: 1, total_pages: 1, total: 0 }, + fluxStudioBusy: false, + fluxJobs: [], + fluxJobSummary: null, + fluxRuntime: null, + fluxStudioWatchers: new Map(), + fluxJobExpanded: new Set(), + fluxJobManuallyToggled: new Set(), + fluxJobCenterCollapsed: false, + fluxJobArchiveMode: false, + fluxJobPage: 1, + fluxJobPageSize: 10, + fluxJobPagination: { page: 1, total_pages: 1, total: 0 }, + fluxJobSelected: new Set(), + mediaItems: [], + mediaSelected: new Set(), + mediaPage: 1, + mediaPageSize: 24, + mediaPagination: { page: 1, total_pages: 1, total: 0 }, + mediaSummary: null, + mediaView: 'grid', + mediaCurrent: null, + imageFactoryProfiles: [], + imageFactorySources: [], + imageFactorySelectedSources: new Set(), + imageFactorySelectedProfiles: new Set(), + imageFactoryJobs: [], + imageFactoryJobPage: 1, + imageFactoryJobPageSize: 10, + imageFactoryJobPagination: { page: 1, total_pages: 1, total: 0 }, + imageFactorySummary: null, + imageFactoryExpanded: new Set(), + qualityItems: [], + qualityPage: 1, + qualityPageSize: 10, + qualityPagination: { page: 1, total_pages: 1, total: 0 }, + qualitySummary: null, + qualityCurrent: null, + qualityIssueFilter: 'all', + qualityAiDraft: null, +}; + +const editors = {}; + +const htmlSyncState = { generatorLock: false, detailLock: false }; +let detailEditorState = null; +const listingEditLock = { listingId: null, token: null, lock: null, timer: null }; +const mobileUploadState = { session: null, pollTimer: null, target: null, imported: new Set() }; + + +const QUILL_TOOLBAR = [ + [{ header: [1, 2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ color: [] }, { background: [] }], + [{ list: 'ordered' }, { list: 'bullet' }], + [{ align: [] }], + ['blockquote', 'link'], + ['clean'], +]; + +function initQuill(container, placeholder) { + if (typeof Quill === 'undefined') return null; + return new Quill(container, { + theme: 'snow', + placeholder: placeholder || '', + modules: { toolbar: QUILL_TOOLBAR }, + }); +} + +const RICH_ALLOWED_TAGS = new Set([ + 'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'OL', 'UL', 'LI', + 'BLOCKQUOTE', 'A', 'H1', 'H2', 'H3', 'SPAN', 'DIV', 'SUB', 'SUP', 'CODE', 'PRE', +]); +const RICH_DANGEROUS_TAGS = new Set([ + 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', + 'SELECT', 'OPTION', 'SVG', 'MATH', 'VIDEO', 'AUDIO', 'LINK', 'META', +]); + +function sanitizeStyleClient(style) { + return String(style || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf(':'); + if (index < 1) return ''; + const name = part.slice(0, index).trim().toLowerCase(); + const value = part.slice(index + 1).trim(); + if (!['color', 'background-color', 'text-align'].includes(name)) return ''; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return ''; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) return ''; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) return ''; + return `${name}:${value}`; + }).filter(Boolean).join(';'); +} + +function sanitizeRichHtmlClient(input) { + const doc = new DOMParser().parseFromString(`${String(input || '')}`, 'text/html'); + const elements = [...doc.body.querySelectorAll('*')].reverse(); + for (const element of elements) { + if (RICH_DANGEROUS_TAGS.has(element.tagName)) { + element.remove(); + continue; + } + if (!RICH_ALLOWED_TAGS.has(element.tagName)) { + element.replaceWith(...element.childNodes); + continue; + } + for (const attr of [...element.attributes]) { + const name = attr.name.toLowerCase(); + const value = attr.value; + let keep = false; + if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:|tel:|#|\/)/i.test(value.trim())) keep = true; + if (element.tagName === 'A' && name === 'target' && ['_blank', '_self'].includes(value)) keep = true; + if (element.tagName === 'A' && name === 'rel') { element.setAttribute('rel', 'noopener noreferrer'); keep = true; } + if (name === 'style') { + const safeStyle = sanitizeStyleClient(value); + if (safeStyle) { element.setAttribute('style', safeStyle); keep = true; } + } + if (name === 'class' && String(value).split(/\s+/).every(cls => /^ql-[a-z0-9-]+$/i.test(cls))) keep = true; + if (!keep) element.removeAttribute(attr.name); + } + if (element.tagName === 'A' && element.getAttribute('target') === '_blank') element.setAttribute('rel', 'noopener noreferrer'); + } + return doc.body.innerHTML.trim(); +} + +function plainTextToRichHtml(text) { + const value = String(text || '').trim(); + if (!value) return ''; + return value.split(/\n{2,}/).map(paragraph => `

                          ${escapeHtml(paragraph).replace(/\n/g, '
                          ')}

                          `).join(''); +} + +function richHtmlToText(html) { + const container = document.createElement('div'); + container.innerHTML = sanitizeRichHtmlClient(html); + container.querySelectorAll('br').forEach(br => br.replaceWith('\n')); + container.querySelectorAll('p,div,h1,h2,h3,li,blockquote,pre').forEach(node => node.append('\n')); + return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); +} + +function getEditorHtml(editor) { + if (!editor) return ''; + const html = sanitizeRichHtmlClient(editor.root.innerHTML); + return /^(


                          <\/p>|

                          <\/p>)$/i.test(html) ? '' : html; +} + +function setEditorHtml(editor, html) { + if (!editor) return; + const safe = sanitizeRichHtmlClient(html); + editor.setText(''); + if (safe) editor.clipboard.dangerouslyPasteHTML(safe); +} + +function setSandboxPreview(frame, html) { + if (!frame) return; + frame.srcdoc = `${String(html || '')}`; +} + +function setHtmlSyncStatus(id, text, tone = '') { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.dataset.tone = tone; +} + +const clientDiagnostics = { incidents: [], lastSystemReport: null, online: true, healthTimer: null }; + +function recordClientIncident(type, message, details = {}) { + const incident = { + at: new Date().toISOString(), + type: String(type || 'client'), + message: String(message || 'Unbekannter Fehler').slice(0, 500), + view: document.querySelector('.tab-content.active')?.id || 'unknown', + build: VENDOO_UI_BUILD, + ...details, + }; + clientDiagnostics.incidents.unshift(incident); + clientDiagnostics.incidents = clientDiagnostics.incidents.slice(0, 30); + try { localStorage.setItem('vendoo_client_incidents', JSON.stringify(clientDiagnostics.incidents)); } catch {} + return incident; +} + +function setConnectionState(online, detail = '') { + clientDiagnostics.online = !!online; + const banner = document.getElementById('connection-banner'); + const title = document.getElementById('connection-banner-title'); + const description = document.getElementById('connection-banner-detail'); + if (!banner) return; + banner.classList.toggle('hidden', online); + banner.classList.toggle('is-online', online); + if (title) title.textContent = online ? 'Verbindung wiederhergestellt' : 'Vendoo ist nicht erreichbar'; + if (description) description.textContent = detail || (online ? 'Die Anwendung ist wieder verbunden.' : 'Verbindung wird automatisch erneut geprüft.'); +} + +async function checkVendooHealth({ quiet = true } = {}) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 3500); + try { + const response = await fetch(`/healthz?t=${Date.now()}`, { cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + const wasOffline = !clientDiagnostics.online; + setConnectionState(true, `Server erreichbar · Version ${result.version || VENDOO_UI_BUILD}`); + if (wasOffline && !quiet) toast('Verbindung zu Vendoo wiederhergestellt.'); + return true; + } catch (error) { + setConnectionState(false, error?.name === 'AbortError' ? 'Zeitüberschreitung beim Serverzugriff.' : 'Serververbindung unterbrochen. Automatische Wiederholung läuft.'); + return false; + } finally { + window.clearTimeout(timer); + } +} + +function setupGlobalResilience() { + try { + const stored = JSON.parse(localStorage.getItem('vendoo_client_incidents') || '[]'); + if (Array.isArray(stored)) clientDiagnostics.incidents = stored.slice(0, 30); + } catch {} + window.addEventListener('error', event => { + recordClientIncident('javascript', event.message, { file: event.filename || '', line: event.lineno || 0, column: event.colno || 0 }); + }); + window.addEventListener('unhandledrejection', event => { + const reason = event.reason; + recordClientIncident('promise', reason?.message || String(reason || 'Unbehandelte Promise-Ablehnung')); + }); + window.addEventListener('offline', () => setConnectionState(false, 'Der Browser meldet keine Netzwerkverbindung.')); + window.addEventListener('online', () => checkVendooHealth({ quiet: false })); + document.getElementById('connection-retry-btn')?.addEventListener('click', () => checkVendooHealth({ quiet: false })); + checkVendooHealth(); + clientDiagnostics.healthTimer = window.setInterval(() => checkVendooHealth(), 30000); + window.addEventListener('beforeunload', () => window.clearInterval(clientDiagnostics.healthTimer), { once: true }); +} + +function applyResponsiveContract() { + const main = document.querySelector('.main-wrapper'); + if (!main) return; + const width = Math.round(main.getBoundingClientRect().width); + const bucket = width < 560 ? 'phone' : width < 820 ? 'narrow' : width < 1120 ? 'compact' : width < 1480 ? 'standard' : 'wide'; + main.dataset.layoutWidth = bucket; + main.style.setProperty('--workspace-width', `${width}px`); + document.querySelectorAll('.tab-content').forEach(section => { + section.dataset.layoutWidth = bucket; + }); +} + +function setupResponsiveContracts() { + applyResponsiveContract(); + const main = document.querySelector('.main-wrapper'); + if (main && 'ResizeObserver' in window) { + const observer = new ResizeObserver(() => window.requestAnimationFrame(applyResponsiveContract)); + observer.observe(main); + window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); + } else { + window.addEventListener('resize', debounce(applyResponsiveContract, 100)); + } +} + +async function init() { + document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD; + console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`); + setupGlobalResilience(); + setupResponsiveContracts(); + // Auth check — redirect to login if not authenticated or setup needed + try { + const meCheck = await fetch('/api/me', { credentials: 'same-origin' }); + if (meCheck.status === 401) { window.location.href = '/login.html'; return; } + const meData = await meCheck.json(); + if (meData.setupMode) { window.location.href = '/login.html'; return; } + } catch {} + + const [platforms, settings, providers, templates] = await Promise.all([ + api('GET', '/api/platforms'), + api('GET', '/api/settings'), + api('GET', '/api/providers'), + api('GET', '/api/templates'), + ]); + + state.platforms = platforms; + state.providers = providers; + state.templates = templates; + state.platform = settings.default_platform || 'vinted'; + state.aiProvider = settings.default_ai || 'claude'; + state.language = settings.default_language || 'de'; + + renderPlatformButtons(); + renderProviderButtons(); + populateSettingsForm(settings, platforms, providers); + populateTemplateSelects(platforms); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('language-select').value = state.language; + + editors.result = initQuill('#result-description-editor', 'Beschreibung wird hier angezeigt...'); + editors.tplNotes = initQuill('#tpl-notes-editor', 'Verkäufernotizen...'); + + setupGeneratorHtmlSync(); + setupHtmlViewSwitches(); + setupMobileUpload(); + + addQuillTooltips(); + + setupTabs(); + setupAnalyticsDashboard(); + setupTodayFlow(); + setupUpload(); + setupImageTools(); + setupGenerate(); + setupGeneratorStudio(); + setupAiPromptModal(); + setupFluxStudio(); + setupMediaLibrary(); + setupImageFactory(); + setupQualityCenter(); + setupExtensions(); + setupCopy(); + setupHistory(); + setupPublish(); + setupTrash(); + setupTemplates(); + setupFeeCalculator(); + setupSettings(); + setupDarkMode(); + setupPhotoEditor(); + setupBackup(); + setupNotifications(); + setupUserMenu(); + setupAdmin(); + loadAnalyticsDashboard(); + updateTrashBadge(); +} + +// --- API --- +let _csrfToken = ''; +function getCsrf() { + if (_csrfToken) return _csrfToken; + const m = document.cookie.match(/csrf_token=([^;]+)/); + return m ? m[1] : ''; +} +async function api(method, url, body, options = {}) { + const verb = String(method || 'GET').toUpperCase(); + const attempts = verb === 'GET' && options.retry !== false ? 2 : 1; + const timeoutMs = Number(options.timeoutMs || (verb === 'GET' ? 20000 : 90000)); + const requestId = globalThis.crypto?.randomUUID?.() || `vd-${Date.now()}-${Math.random().toString(16).slice(2)}`; + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + const opts = { method: verb, headers: { 'X-Vendoo-Request-ID': requestId, ...(options.headers || {}) }, credentials: 'same-origin', signal: controller.signal }; + if (body !== undefined && body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD'].includes(verb)) opts.headers['X-CSRF-Token'] = getCsrf(); + try { + const res = await fetch(url, opts); + if (res.status === 401) { window.location.href = '/login.html'; return; } + const contentType = res.headers.get('content-type') || ''; + const payload = contentType.includes('application/json') + ? await res.json().catch(() => ({})) + : await res.text().catch(() => ''); + if (!res.ok) { + const error = new Error(payload?.error || payload?.message || (typeof payload === 'string' && payload) || res.statusText || 'Fehler'); + error.status = res.status; + error.requestId = requestId; + if (attempt < attempts && res.status >= 500) { lastError = error; continue; } + throw error; + } + if (!clientDiagnostics.online) setConnectionState(true, 'Serververbindung wiederhergestellt.'); + return payload; + } catch (error) { + lastError = error?.name === 'AbortError' ? new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`) : error; + lastError.requestId = requestId; + const retryable = attempt < attempts && (error?.name === 'AbortError' || error instanceof TypeError || Number(error?.status || 0) >= 500); + if (retryable) { await new Promise(resolve => window.setTimeout(resolve, 250)); continue; } + recordClientIncident('api', lastError.message, { method: verb, url, requestId, status: lastError.status || 0 }); + if (error instanceof TypeError || error?.name === 'AbortError') setConnectionState(false, `${lastError.message} · Anfrage ${requestId.slice(0, 8)}`); + throw lastError; + } finally { + window.clearTimeout(timer); + } + } + throw lastError || new Error('Anfrage fehlgeschlagen'); +} + + +function aiModelStatusClass(tone = 'neutral') { + return `ai-model-status ${tone ? `is-${tone}` : ''}`.trim(); +} + +function setAiModelStatus(elementId, message, tone = 'neutral') { + const element = document.getElementById(elementId); + if (!element) return; + element.textContent = message; + element.className = aiModelStatusClass(tone); +} + + +function setAiModelJobProgress(elementId, job) { + const element = document.getElementById(elementId); + if (!element) return; + const progress = Math.max(0, Math.min(100, Number(job?.progress || 0))); + const message = job?.status === 'failed' + ? (job.error_message || 'AI-Bildjob fehlgeschlagen') + : (job?.progress_message || formatAiJobStatus(job)); + const tone = job?.status === 'failed' ? 'danger' : job?.status === 'completed' ? 'success' : 'working'; + element.className = `${aiModelStatusClass(tone)} ai-job-progress-card`; + element.innerHTML = ` +

                          ${escapeHtml(message)}${progress}%
                          +
                          + ${job?.status === 'queued' ? 'Warteschlange' : job?.status === 'running' ? 'Lokale Verarbeitung läuft' : job?.status === 'completed' ? 'Abgeschlossen' : 'Aktion beendet'} + `; +} + +function getGeneratorAiModelPayload() { + return { + photos: [...state.photos], + platform: state.platform, + title: document.getElementById('result-title')?.value || state.currentListing?.title || '', + category: metaCategoryPicker?.getValue?.() || state.currentListing?.category || '', + brand: document.getElementById('meta-brand')?.value || state.currentListing?.brand || '', + color: document.getElementById('meta-color')?.value || state.currentListing?.color || '', + size: document.getElementById('meta-size')?.value || state.currentListing?.size || '', + condition: document.getElementById('meta-condition')?.value || state.currentListing?.condition || '', + seller_notes: document.getElementById('generator-seller-notes')?.value || state.currentListing?.seller_notes || '', + mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('generator-ai-model-variants')?.value || '1', 10), + }; +} + +function buildAiModelCardHtml(items, scope) { + const prefix = scope === 'detail' ? 'detail' : 'generator'; + return ` +
                          + ${items.length} sichere Bildvariante${items.length === 1 ? '' : 'n'} + +
                          +
                          + ${items.map((photo, index) => ` +
                          + Produktbild-Variante ${index + 1} +
                          + Variante ${photo.variant_index || index + 1} + ${escapeHtml((photo.mode || 'flatlay').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())} +
                          + +
                          `).join('')} +
                          `; +} + + +function renderAiModelGrid(scope, items = []) { + const grid = document.getElementById(scope === 'detail' ? 'detail-ai-model-grid' : 'generator-ai-model-grid'); + if (!grid) return; + if (!items.length) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + return; + } + grid.innerHTML = buildAiModelCardHtml(items, scope); + grid.classList.remove('hidden'); + + const addOne = filename => { + if (scope === 'detail') { + if (!detailEditorState) return; + if (!detailEditorState.photos.includes(filename)) detailEditorState.photos.push(filename); + detailEditorState.activePhotoIndex = detailEditorState.photos.length - 1; + renderDetailPhotos(); + } else { + if (!state.photos.includes(filename)) state.photos.push(filename); + renderPhotoPreviews(); + updateGenerateButton(); + } + toast('AI-Model-Foto hinzugefügt'); + }; + + grid.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => addOne(button.dataset.aiModelAddOne))); + grid.querySelector('[data-ai-model-add-all]')?.addEventListener('click', () => { + items.forEach(photo => addOne(photo.image_path || photo.filename)); + toast('Alle AI-Model-Fotos hinzugefügt'); + }); +} + +async function pollAiModelJob(jobId, onProgress) { + const started = Date.now(); + while (Date.now() - started < 360000) { + const job = await api('GET', `/api/ai-model-photos/jobs/${jobId}`); + if (typeof onProgress === 'function') onProgress(job); + if (job.status === 'completed' || job.status === 'failed') return job; + await new Promise(resolve => setTimeout(resolve, 1800)); + } + throw new Error('Zeitüberschreitung bei der AI-Bildgenerierung'); +} + +function formatAiJobStatus(job) { + if (!job) return 'AI-Bildjob wird vorbereitet …'; + if (job.status === 'queued') return job.progress_message || 'AI-Bildjob steht in der Warteschlange …'; + if (job.status === 'running') return job.progress_message || `AI-Bilder werden erzeugt … ${job.progress || 1}%`; + if (job.status === 'failed') return job.error_message || 'AI-Bildjob fehlgeschlagen'; + return job.progress_message || 'AI-Bildjob abgeschlossen'; +} + + +function getAiPromptDraft(scope = 'generator') { + if (!state.aiPromptDrafts[scope]) state.aiPromptDrafts[scope] = { customPrompt: '', preserveLogos: true }; + return state.aiPromptDrafts[scope]; +} + +function getAiPromptContext(scope = 'generator') { + if (scope === 'detail') { + return { + mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed', + variants: document.getElementById('detail-ai-model-variants')?.value || '1', + }; + } + return { + mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed', + variants: document.getElementById('generator-ai-model-variants')?.value || '1', + }; +} + +function updateAiPromptSummary(scope = 'generator') { + const ctx = getAiPromptContext(scope); + const modeLabel = ctx.mode === 'ghost_mannequin' ? 'Ghost Mannequin' : 'Flatlay'; + const lookLabel = ctx.preset === 'mixed' ? 'Mix' : ctx.preset === 'studio' ? 'Studio' : ctx.preset === 'indoor' ? 'Indoor' : 'Outdoor'; + document.getElementById('ai-prompt-mode-chip').textContent = `Modus: ${modeLabel}`; + document.getElementById('ai-prompt-look-chip').textContent = `Look: ${lookLabel}`; + document.getElementById('ai-prompt-variants-chip').textContent = `Varianten: ${ctx.variants}`; + const logoCheck = document.getElementById('ai-prompt-preserve-logos'); + if (logoCheck) { + logoCheck.disabled = false; + } +} + +function closeAiPromptModal() { + const modal = document.getElementById('ai-prompt-modal'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('ai-prompt-open'); +} + +function syncAiPromptDraftFromModal() { + const scope = state.aiPromptScope || 'generator'; + const draft = getAiPromptDraft(scope); + draft.customPrompt = document.getElementById('ai-prompt-textarea')?.value || ''; + draft.preserveLogos = document.getElementById('ai-prompt-preserve-logos')?.checked !== false; +} + +function openAiPromptModal(scope = 'generator') { + const modal = document.getElementById('ai-prompt-modal'); + if (!modal) return; + state.aiPromptScope = scope; + const draft = getAiPromptDraft(scope); + document.getElementById('ai-prompt-scope-copy').textContent = scope === 'detail' ? 'Zusatzanweisung für das aktuelle Listing' : 'Zusatzanweisung für den Generator'; + document.getElementById('ai-prompt-textarea').value = draft.customPrompt || ''; + document.getElementById('ai-prompt-preserve-logos').checked = draft.preserveLogos !== false; + updateAiPromptSummary(scope); + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('ai-prompt-open'); + setTimeout(() => document.getElementById('ai-prompt-textarea')?.focus(), 30); +} + +function setupAiPromptModal() { + const modal = document.getElementById('ai-prompt-modal'); + if (!modal) return; + modal.querySelectorAll('[data-ai-prompt-close]').forEach(btn => btn.addEventListener('click', closeAiPromptModal)); + document.getElementById('ai-prompt-textarea')?.addEventListener('input', syncAiPromptDraftFromModal); + document.getElementById('ai-prompt-preserve-logos')?.addEventListener('change', syncAiPromptDraftFromModal); + document.getElementById('ai-prompt-reset-btn')?.addEventListener('click', () => { + const draft = getAiPromptDraft(state.aiPromptScope || 'generator'); + draft.customPrompt = ''; + draft.preserveLogos = true; + document.getElementById('ai-prompt-textarea').value = ''; + document.getElementById('ai-prompt-preserve-logos').checked = true; + }); + document.getElementById('ai-prompt-run-btn')?.addEventListener('click', async () => { + syncAiPromptDraftFromModal(); + const scope = state.aiPromptScope || 'generator'; + closeAiPromptModal(); + if (scope === 'detail') await runDetailAiModelPhotos(); + else await runGeneratorAiModelPhotos(); + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !modal.classList.contains('hidden')) closeAiPromptModal(); + }); +} + + + +const FLUX_PROMPT_TEMPLATES = { + product: 'Professionelle Produktfotografie, klares Hauptmotiv, neutraler Studiohintergrund, weiches Licht, realistische Materialien, hochwertige kommerzielle Bildsprache', + social: 'Modernes Social-Media-Motiv, klarer visueller Fokus, starke aber ausgewogene Komposition, hochwertige Beleuchtung, sofort verständlich', + editorial: 'Premium Editorial-Fotografie, anspruchsvolle Magazinkomposition, kontrolliertes Licht, elegante Farbwelt, hochwertige realistische Details', + background: 'Minimalistischer heller Studiohintergrund, weiche natürliche Schatten, dezente Tiefe, sauber, hochwertig, ohne Text und ohne Personen', + lifestyle: 'Authentische hochwertige Lifestyle-Szene, natürliches Tageslicht, glaubwürdige Umgebung, harmonische Komposition, fotorealistisch', + banner: 'Breites hochwertiges Werbemotiv, klarer freier Bereich für spätere Texte, kontrollierte Beleuchtung, moderne kommerzielle Gestaltung', +}; + +const FLUX_PROFILE_PRESETS = { + fast: { format: '768x768', steps: '4' }, + balanced: { format: '1024x1024', steps: '4' }, + quality: { format: '1024x1024', steps: '6' }, + custom: null, +}; + +const FLUX_TERMINAL_JOB_STATUSES = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled']); +const FLUX_ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']); + +function analyzeFluxPrompt(value = '') { + const text = String(value || '').trim(); + if (!text) return { level: 'empty', copy: 'Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.' }; + const words = text.split(/\s+/).filter(Boolean).length; + const signals = [ + /licht|lighting|beleuchtung/i, + /hintergrund|background|umgebung|scene|studio/i, + /kamera|camera|perspektive|perspective|close-up|nahaufnahme|wide shot/i, + /farbe|color|material|texture|oberfläche/i, + ].filter(pattern => pattern.test(text)).length; + if (words < 8) return { level: 'weak', copy: 'Noch sehr kurz. Ergänze Motiv, Umgebung, Licht und Bildausschnitt.' }; + if (words < 18 || signals < 2) return { level: 'good', copy: 'Gute Basis. Der Prompt-Assistent kann Komposition und technische Bildsprache ergänzen.' }; + return { level: 'strong', copy: 'Starker Prompt mit ausreichend visueller Führung.' }; +} + +function refreshFluxPromptQuality() { + const input = document.getElementById('flux-studio-prompt'); + const box = document.getElementById('flux-prompt-quality'); + const copy = document.getElementById('flux-prompt-quality-copy'); + if (!box || !copy) return; + const result = analyzeFluxPrompt(input?.value || ''); + box.dataset.level = result.level; + copy.textContent = result.copy; +} + +function optimizeFluxPromptLocally(value = '') { + let text = String(value || '').trim().replace(/\s+/g, ' '); + if (!text) return text; + const additions = []; + if (!/licht|lighting|beleuchtung/i.test(text)) additions.push('weiches gerichtetes Licht mit realistischen Schatten'); + if (!/kamera|camera|perspektive|perspective|aufnahme|shot/i.test(text)) additions.push('klarer professioneller Bildausschnitt auf Augenhöhe'); + if (!/hintergrund|background|umgebung|scene|studio/i.test(text)) additions.push('ruhige, zum Motiv passende Umgebung'); + if (!/detail|material|texture|oberfläche/i.test(text)) additions.push('realistische Materialien und feine Oberflächendetails'); + if (additions.length) text = `${text.replace(/[,. ]+$/, '')}, ${additions.join(', ')}`; + return text; +} + +function applyFluxProfile(profile, { persist = true } = {}) { + const normalized = FLUX_PROFILE_PRESETS[profile] ? profile : 'custom'; + const hidden = document.getElementById('flux-studio-profile'); + if (hidden) hidden.value = normalized; + document.querySelectorAll('[data-flux-profile]').forEach(button => button.classList.toggle('active', button.dataset.fluxProfile === normalized)); + const preset = FLUX_PROFILE_PRESETS[normalized]; + if (preset) { + const format = document.getElementById('flux-studio-format'); + const steps = document.getElementById('flux-studio-steps'); + if (format) format.value = preset.format; + if (steps) steps.value = preset.steps; + } + if (persist) { try { localStorage.setItem('vendoo-flux-profile', normalized); } catch {} } +} + +function setFluxVariantCount(value, { persist = true } = {}) { + const normalized = [1, 2, 4].includes(Number(value)) ? Number(value) : 1; + const input = document.getElementById('flux-studio-variants'); + if (input) input.value = String(normalized); + document.querySelectorAll('[data-flux-variants]').forEach(button => button.classList.toggle('active', Number(button.dataset.fluxVariants) === normalized)); + if (persist) { try { localStorage.setItem('vendoo-flux-variants', String(normalized)); } catch {} } +} + +function resetFluxStudioWorkspace({ clearSavedDefaults = false, announce = false } = {}) { + state.fluxStudioCurrent = null; + renderFluxStudioPreview(null); + const variants = document.getElementById('flux-studio-variant-results'); + if (variants) { variants.innerHTML = ''; variants.classList.add('hidden'); } + + const prompt = document.getElementById('flux-studio-prompt'); + if (prompt) { prompt.value = ''; prompt.dispatchEvent(new Event('input')); } + const mode = document.getElementById('flux-studio-prompt-mode'); + if (mode) mode.value = 'precise'; + const style = document.getElementById('flux-studio-style'); + if (style) style.value = 'photorealistic'; + const seed = document.getElementById('flux-studio-seed'); + if (seed) seed.value = '42'; + const lock = document.getElementById('flux-studio-seed-lock'); + if (lock) lock.checked = false; + applyFluxProfile('balanced', { persist: !clearSavedDefaults }); + setFluxVariantCount(1, { persist: !clearSavedDefaults }); + if (clearSavedDefaults) { + try { + localStorage.removeItem('vendoo-flux-profile'); + localStorage.removeItem('vendoo-flux-variants'); + } catch {} + } + setFluxStudioStatus('Bereit. Noch keine Auswahl.', ''); + setFluxStudioProgress('', false); + renderFluxStudioHistory(); + if (announce) toast('FLUX Studio wurde auf einen leeren Arbeitsbereich zurückgesetzt. Verlauf und Bilder bleiben erhalten.'); +} + +function parseFluxFormat() { + const value = document.getElementById('flux-studio-format')?.value || '768x768'; + const [width, height] = value.split('x').map(Number); + return { width: width || 768, height: height || 768 }; +} + +function setFluxStudioStatus(message, tone = '') { + const status = document.getElementById('flux-studio-status'); + if (!status) return; + status.textContent = message; + status.dataset.tone = tone; +} + +function setFluxStudioProgress(label = 'Vorbereitung', visible = true, stateName = 'active') { + const shell = document.getElementById('flux-studio-progress'); + const value = document.getElementById('flux-studio-progress-value'); + const text = document.getElementById('flux-studio-progress-label'); + shell?.classList.toggle('hidden', !visible); + if (shell) shell.dataset.state = stateName; + if (value) value.textContent = stateName === 'done' ? 'Fertig' : stateName === 'error' ? 'Fehler' : stateName === 'cancelled' ? 'Abgebrochen' : 'Aktive Phase'; + if (text) text.textContent = label || 'FLUX arbeitet …'; +} + +function formatFluxDate(value) { + if (!value) return '–'; + const normalized = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }); +} + +function formatFluxDuration(ms) { + const seconds = Math.max(0, Math.round(Number(ms || 0) / 1000)); + if (seconds < 60) return `${seconds} s`; + return `${Math.floor(seconds / 60)} min ${seconds % 60} s`; +} + +function fluxStatusLabel(status) { + return ({ + queued: 'Wartend', running: 'Läuft', cancelling: 'Wird abgebrochen', completed: 'Erfolgreich', + completed_with_errors: 'Teilweise fertig', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', + })[status] || status || 'Unbekannt'; +} + +async function refreshFluxStudioEngineStatus() { + const chip = document.getElementById('flux-studio-engine-chip'); + try { + const status = await api('GET', '/api/local-image/status'); + const ready = !!status.reachable; + if (chip) { + chip.textContent = status.engine_label || (ready ? 'Aktiv und bereit' : 'Nicht bereit'); + chip.className = `settings-status-chip ${ready ? 'is-ready' : ['stopped','starting'].includes(status.engine_state) ? 'is-warning' : 'is-disabled'}`; + } + document.getElementById('flux-studio-generate')?.toggleAttribute('disabled', !ready || state.fluxStudioBusy); + return status; + } catch (error) { + if (chip) { chip.textContent = 'Status nicht verfügbar'; chip.className = 'settings-status-chip is-disabled'; } + return null; + } +} + +async function waitForFluxEngineReady({ statusElement = null, timeoutMs = 190000 } = {}) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const status = await refreshFluxStudioEngineStatus(); + if (status?.reachable) { + if (statusElement) statusElement.textContent = 'Lokaler FLUX/ComfyUI-Server ist aktiv und bereit.'; + return status; + } + const runtime = status?.runtime_state || {}; + const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + const detail = runtime.message || status?.message || 'ComfyUI wird initialisiert.'; + if (statusElement) statusElement.textContent = `FLUX startet seit ${elapsed}s · ${detail}`; + setFluxStudioStatus(`FLUX startet seit ${elapsed}s · ${detail}`, 'working'); + if (runtime.status === 'failed' || status?.engine_state === 'failed') { + const tail = [...(runtime.stderr_tail || []), ...(runtime.stdout_tail || [])].filter(Boolean).slice(-6).join(' | '); + throw new Error(runtime.error || tail || runtime.message || 'ComfyUI wurde während des Starts beendet.'); + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error('ComfyUI war nach 190 Sekunden noch nicht erreichbar. Prüfe die Runtime-Protokolle in Vendoo/logs.'); +} + +function fluxVariantToImage(job, variant) { + if (!variant?.image_path) return null; + return { + id: variant.image_id, + job_id: job.id, + variant_index: variant.variant_index, + prompt: job.prompt, + final_prompt: job.final_prompt, + image_path: variant.image_path, + public_url: variant.public_url, + width: job.width, + height: job.height, + seed: variant.seed, + steps: job.steps, + style: job.style, + profile: job.profile, + prompt_mode: job.prompt_mode, + duration_ms: variant.duration_ms, + favorite: variant.favorite, + parameters: { + width: job.width, height: job.height, steps: job.steps, style: job.style, profile: job.profile, + prompt_mode: job.prompt_mode, variants: job.variants_count, seed_lock: job.seed_lock, + }, + }; +} + +function renderFluxVariantResults(job) { + const container = document.getElementById('flux-studio-variant-results'); + if (!container) return; + const completed = (job?.variants || []).filter(variant => variant.image_path); + container.classList.toggle('hidden', !completed.length); + if (!completed.length) { container.innerHTML = ''; return; } + container.innerHTML = completed.map(variant => { + const item = fluxVariantToImage(job, variant); + const active = Number(state.fluxStudioCurrent?.id) === Number(item.id); + return ``; + }).join(''); + container.querySelectorAll('[data-flux-variant-image]').forEach(button => button.addEventListener('click', () => { + const variant = completed.find(entry => String(entry.image_id) === button.dataset.fluxVariantImage); + const item = fluxVariantToImage(job, variant); + if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); } + })); +} + +function renderFluxStudioPreview(item) { + state.fluxStudioCurrent = item || null; + const image = document.getElementById('flux-studio-preview-image'); + const empty = document.getElementById('flux-studio-preview-empty'); + const actions = document.getElementById('flux-studio-preview-actions'); + const meta = document.getElementById('flux-studio-preview-meta'); + const download = document.getElementById('flux-studio-download'); + const favorite = document.getElementById('flux-studio-favorite'); + if (!item?.image_path) { + image?.classList.add('hidden'); + empty?.classList.remove('hidden'); + actions?.classList.add('hidden'); + if (meta) meta.textContent = 'Noch kein Bild'; + return; + } + if (image) { + image.src = item.public_url || photoUrl(item.image_path); + image.alt = item.prompt ? `FLUX: ${item.prompt}` : 'FLUX-Vorschau'; + image.classList.remove('hidden'); + } + empty?.classList.add('hidden'); + actions?.classList.remove('hidden'); + if (meta) meta.textContent = `${item.width || '–'} × ${item.height || '–'} · Seed ${item.seed ?? '–'} · ${item.steps || '–'} Schritte${item.duration_ms ? ` · ${formatFluxDuration(item.duration_ms)}` : ''}`; + if (download) { + download.href = item.public_url || photoUrl(item.image_path); + download.download = `vendoo-flux-${item.id || Date.now()}.${String(item.image_path).split('.').pop() || 'png'}`; + } + if (favorite) favorite.textContent = item.favorite ? '★ Favorit' : '☆ Favorit'; +} + +function renderFluxStudioHistory() { + const container = document.getElementById('flux-studio-history'); + if (!container) return; + if (!state.fluxStudioHistory.length) { + container.innerHTML = '
                          Noch keine passenden FLUX-Bilder gespeichert.
                          '; + return; + } + container.innerHTML = state.fluxStudioHistory.map(item => `
                          + +
                          ${escapeHtml(item.prompt || 'FLUX-Bild')}${item.width} × ${item.height} · Seed ${item.seed ?? '–'} · ${formatFluxDate(item.created_at)}
                          +
                          +
                          `).join(''); + container.querySelectorAll('[data-flux-select]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSelect); + if (item) { renderFluxStudioPreview(item); renderFluxStudioHistory(); } + })); + container.querySelectorAll('[data-flux-favorite]').forEach(button => button.addEventListener('click', async () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxFavorite); + if (item) await toggleFluxFavorite(item); + })); + container.querySelectorAll('[data-flux-similar]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSimilar); + if (item) createSimilarFluxJob(item); + })); + container.querySelectorAll('[data-flux-edit]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxEdit); + if (item) { renderFluxStudioPreview(item); openPhotoEditor(0, 'flux'); } + })); + container.querySelectorAll('[data-flux-delete]').forEach(button => button.addEventListener('click', async () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxDelete); + if (item) await deleteFluxStudioImage(item); + })); +} + +function renderFluxHistoryPagination() { + const pagination = state.fluxHistoryPagination || {}; + const page = Number(pagination.page || 1); + const pages = Number(pagination.total_pages || 1); + const total = Number(pagination.total || 0); + document.getElementById('flux-history-page-info')?.replaceChildren(document.createTextNode(`Seite ${page} von ${pages} · ${total} Bilder`)); + const prev = document.getElementById('flux-history-prev'); + const next = document.getElementById('flux-history-next'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; +} + +async function loadFluxStudioHistory({ silent = false } = {}) { + const container = document.getElementById('flux-studio-history'); + if (!silent && container && !state.fluxStudioHistory.length) container.innerHTML = '
                          FLUX-Bibliothek wird geladen …
                          '; + try { + const favorites = document.getElementById('flux-studio-favorites-only')?.checked ? '1' : '0'; + const result = await api('GET', `/api/flux-studio/images?page=${state.fluxHistoryPage}&page_size=${state.fluxHistoryPageSize}&favorites=${favorites}`); + state.fluxStudioHistory = result.items || []; + state.fluxHistoryPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxStudioHistory.length }; + state.fluxHistoryPage = Number(state.fluxHistoryPagination.page || 1); + if (state.fluxStudioCurrent) { + const refreshed = state.fluxStudioHistory.find(item => item.id === state.fluxStudioCurrent.id); + if (refreshed) renderFluxStudioPreview(refreshed); + else renderFluxStudioPreview(null); + } + renderFluxStudioHistory(); + renderFluxHistoryPagination(); + } catch (error) { + if (container) container.innerHTML = `
                          Bibliothek konnte nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +async function toggleFluxFavorite(item = state.fluxStudioCurrent) { + if (!item?.id) return; + try { + const updated = await api('PATCH', `/api/flux-studio/images/${item.id}/favorite`, { favorite: !item.favorite }); + state.fluxStudioHistory = state.fluxStudioHistory.map(entry => entry.id === updated.id ? updated : entry); + if (state.fluxStudioCurrent?.id === updated.id) renderFluxStudioPreview(updated); + await loadFluxStudioHistory({ silent: true }); + toast(updated.favorite ? 'Als Favorit gespeichert.' : 'Favorit entfernt.'); + } catch (error) { toast(`Favorit konnte nicht geändert werden: ${error.message}`); } +} + +async function deleteFluxStudioImage(item = state.fluxStudioCurrent) { + if (!item?.id) return; + if (!window.confirm('Dieses FLUX-Bild wirklich dauerhaft löschen?')) return; + try { + await api('DELETE', `/api/flux-studio/images/${item.id}`); + if (state.fluxStudioCurrent?.id === item.id) renderFluxStudioPreview(null); + await loadFluxStudioHistory({ silent: true }); + toast('FLUX-Bild gelöscht.'); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function getFluxStudioPayload() { + const { width, height } = parseFluxFormat(); + return { + prompt: document.getElementById('flux-studio-prompt')?.value.trim() || '', + width, + height, + seed: Number(document.getElementById('flux-studio-seed')?.value || 42), + steps: Number(document.getElementById('flux-studio-steps')?.value || 4), + style: document.getElementById('flux-studio-style')?.value || 'none', + profile: document.getElementById('flux-studio-profile')?.value || 'balanced', + prompt_mode: document.getElementById('flux-studio-prompt-mode')?.value || 'precise', + assistant: true, + variants: Number(document.getElementById('flux-studio-variants')?.value || 1), + seed_lock: !!document.getElementById('flux-studio-seed-lock')?.checked, + }; +} + +function applyFluxJobToForm(jobOrImage) { + const item = jobOrImage || {}; + const params = item.parameters || {}; + const prompt = document.getElementById('flux-studio-prompt'); + if (prompt && item.prompt) { prompt.value = item.prompt; prompt.dispatchEvent(new Event('input')); } + const format = `${item.width || params.width || 768}x${item.height || params.height || 768}`; + const formatSelect = document.getElementById('flux-studio-format'); + if (formatSelect && [...formatSelect.options].some(option => option.value === format)) formatSelect.value = format; + const steps = document.getElementById('flux-studio-steps'); + if (steps && (item.steps || params.steps)) steps.value = String(item.steps || params.steps); + const style = document.getElementById('flux-studio-style'); if (style) style.value = item.style || params.style || 'none'; + const mode = document.getElementById('flux-studio-prompt-mode'); if (mode) mode.value = item.prompt_mode || params.prompt_mode || 'precise'; + const seed = document.getElementById('flux-studio-seed'); if (seed && Number.isFinite(Number(item.seed ?? item.seed_base))) seed.value = String(item.seed ?? item.seed_base); + const lock = document.getElementById('flux-studio-seed-lock'); if (lock) lock.checked = !!(item.seed_lock ?? params.seed_lock); + setFluxVariantCount(item.variants_count || params.variants || 1); + applyFluxProfile(item.profile || params.profile || 'custom'); +} + +async function watchFluxJob(jobId) { + if (!jobId || state.fluxStudioWatchers.has(jobId)) return; + state.fluxStudioWatchers.set(jobId, true); + try { + const deadline = Date.now() + 12 * 60 * 1000; + while (Date.now() < deadline) { + const job = await api('GET', `/api/flux-prompt/jobs/${encodeURIComponent(jobId)}`); + const activeVariant = (job.variants || []).find(variant => variant.status === 'running') || (job.variants || []).find(variant => variant.status === 'queued'); + const label = activeVariant?.message || job.message || fluxStatusLabel(job.status); + const stateName = job.status === 'failed' ? 'error' : job.status === 'cancelled' ? 'cancelled' : FLUX_TERMINAL_JOB_STATUSES.has(job.status) ? 'done' : 'active'; + setFluxStudioProgress(label, true, stateName); + setFluxStudioStatus(label, stateName === 'error' ? 'error' : stateName === 'done' ? 'success' : 'working'); + const images = (job.variants || []).filter(variant => variant.image_path); + if (images.length) { + const selected = fluxVariantToImage(job, images[0]); + if (selected) renderFluxStudioPreview(selected); + renderFluxVariantResults(job); + } + await loadFluxJobs({ silent: true }); + if (FLUX_TERMINAL_JOB_STATUSES.has(job.status)) { + await loadFluxStudioHistory(); + await refreshFluxRuntimeInfo({ silent: true }); + if (job.status === 'failed') toast(`FLUX-Auftrag fehlgeschlagen: ${job.error || 'Unbekannter Fehler'}`); + else if (job.status === 'cancelled') toast('FLUX-Auftrag wurde abgebrochen.'); + else toast(job.status === 'completed_with_errors' ? 'FLUX-Auftrag teilweise abgeschlossen.' : 'FLUX-Auftrag abgeschlossen.'); + return job; + } + await new Promise(resolve => setTimeout(resolve, 1400)); + } + setFluxStudioStatus('Der Auftrag läuft weiter im Job-Center.', 'working'); + } catch (error) { + setFluxStudioStatus(error.message, 'error'); + } finally { + state.fluxStudioWatchers.delete(jobId); + } +} + +async function runFluxStudioGeneration(payloadOverride = null) { + if (state.fluxStudioBusy) return; + const payload = payloadOverride || getFluxStudioPayload(); + if (String(payload.prompt || '').trim().length < 3) return toast('Bitte einen aussagekräftigen Prompt eingeben.'); + const button = document.getElementById('flux-studio-generate'); + state.fluxStudioBusy = true; + if (button) button.disabled = true; + setFluxStudioProgress('Auftrag wird dauerhaft gespeichert …', true, 'active'); + setFluxStudioStatus('FLUX-Auftrag wird erstellt …', 'working'); + try { + const queued = await api('POST', '/api/flux-prompt/jobs', payload); + setFluxStudioStatus(`${queued.variants_count} Variante${queued.variants_count === 1 ? '' : 'n'} wurde${queued.variants_count === 1 ? '' : 'n'} eingereiht.`, 'success'); + await loadFluxJobs({ silent: true }); + watchFluxJob(queued.id); + return queued; + } catch (error) { + setFluxStudioStatus(error.message, 'error'); + setFluxStudioProgress('Auftrag konnte nicht erstellt werden.', true, 'error'); + toast(`FLUX-Fehler: ${error.message}`); + return null; + } finally { + state.fluxStudioBusy = false; + if (button) button.disabled = false; + refreshFluxStudioEngineStatus(); + } +} + +async function createSimilarFluxJob(item = state.fluxStudioCurrent) { + if (!item?.prompt) return; + applyFluxJobToForm(item); + const seed = Math.floor(Math.random() * 2147483647); + const seedInput = document.getElementById('flux-studio-seed'); if (seedInput) seedInput.value = String(seed); + toast('Prompt und Parameter wurden übernommen; der neue Basis-Seed erzeugt ähnliche Varianten.'); + await runFluxStudioGeneration(); +} + +function renderFluxJobSummary() { + const container = document.getElementById('flux-job-summary'); + if (!container) return; + const summary = state.fluxJobSummary || {}; + const jobs = summary.jobs || {}; + const variants = summary.variants || {}; + document.getElementById('flux-job-archive-count')?.replaceChildren(document.createTextNode(String(summary.archived_total || 0))); + container.innerHTML = state.fluxJobArchiveMode + ? `
                          Archivierte Aufträge${summary.archived_total || 0}
                          +
                          Auf dieser Seite${state.fluxJobs.length}
                          +
                          Seiten${state.fluxJobPagination.total_pages || 1}
                          +
                          Bilder bleiben erhaltenJa
                          ` + : `
                          Aktive Worker${summary.active_workers || 0} / ${summary.max_parallel || 1}
                          +
                          Wartende Varianten${variants.queued || 0}
                          +
                          Laufende Varianten${variants.running || 0}
                          +
                          Fertige Aufträge${(jobs.completed || 0) + (jobs.completed_with_errors || 0)}
                          `; +} + +function updateFluxJobToolbar() { + const count = state.fluxJobSelected.size; + const archiveMode = state.fluxJobArchiveMode; + document.getElementById('flux-job-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`)); + const selectPage = document.getElementById('flux-job-select-page'); + if (selectPage) { + const selectedOnPage = state.fluxJobs.filter(job => state.fluxJobSelected.has(job.id)).length; + selectPage.checked = state.fluxJobs.length > 0 && selectedOnPage === state.fluxJobs.length; + selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.fluxJobs.length; + } + const archive = document.getElementById('flux-job-archive-selected'); + const restore = document.getElementById('flux-job-restore-selected'); + const remove = document.getElementById('flux-job-delete-selected'); + const removeAll = document.getElementById('flux-job-delete-all-archive'); + archive?.classList.toggle('hidden', archiveMode); + restore?.classList.toggle('hidden', !archiveMode); + remove?.classList.toggle('hidden', !archiveMode); + removeAll?.classList.toggle('hidden', !archiveMode); + if (archive) archive.disabled = count === 0; + if (restore) restore.disabled = count === 0; + if (remove) remove.disabled = count === 0; + document.getElementById('flux-job-clear-queue')?.classList.toggle('hidden', archiveMode); + document.getElementById('flux-job-filter')?.classList.toggle('hidden', archiveMode); + document.getElementById('flux-job-view-active')?.classList.toggle('active', !archiveMode); + document.getElementById('flux-job-view-archive')?.classList.toggle('active', archiveMode); +} + +function renderFluxJobPagination() { + const pagination = state.fluxJobPagination || {}; + const page = Number(pagination.page || 1); + const pages = Number(pagination.total_pages || 1); + const total = Number(pagination.total || 0); + const info = document.getElementById('flux-job-page-info'); + if (info) info.textContent = `Seite ${page} von ${pages} · ${total} Auftrag${total === 1 ? '' : 'e'}`; + const prev = document.getElementById('flux-job-prev'); + const next = document.getElementById('flux-job-next'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; +} + +async function archiveFluxJobs(ids) { + if (!ids.length) return; + const result = await api('POST', '/api/flux-prompt/jobs/archive', { ids }); + state.fluxJobSelected.clear(); + toast(`${result.archived || 0} Auftrag/Aufträge archiviert${result.skipped ? ` · ${result.skipped} aktiv oder nicht archiviert` : ''}.`); + await loadFluxJobs(); +} + +async function restoreFluxJobs(ids) { + if (!ids.length) return; + const result = await api('POST', '/api/flux-prompt/jobs/restore', { ids }); + state.fluxJobSelected.clear(); + toast(`${result.restored || 0} Auftrag/Aufträge wiederhergestellt.`); + await loadFluxJobs(); +} + +async function deleteArchivedFluxJobs(ids = [], all = false) { + const message = all + ? 'Das komplette FLUX-Auftragsarchiv endgültig löschen? Die erzeugten Bilder bleiben in der Bildergalerie erhalten.' + : `${ids.length} archivierte Auftrag/Aufträge endgültig löschen? Die erzeugten Bilder bleiben erhalten.`; + if (!window.confirm(message)) return; + const result = await api('DELETE', `/api/flux-prompt/jobs/archive${all ? '?all=1' : ''}`, { ids }); + state.fluxJobSelected.clear(); + toast(`${result.deleted || 0} archivierte Auftrag/Aufträge gelöscht.`); + await loadFluxJobs(); +} + +function renderFluxJobList() { + const container = document.getElementById('flux-job-list'); + if (!container) return; + const jobs = state.fluxJobs; + if (!jobs.length) { + container.innerHTML = `
                          ${state.fluxJobArchiveMode ? 'Das Auftragsarchiv ist leer.' : 'Keine Aufträge für diesen Filter.'}
                          `; + updateFluxJobToolbar(); + return; + } + container.innerHTML = jobs.map(job => { + const variants = job.variants || []; + const expanded = state.fluxJobExpanded.has(job.id); + const selected = state.fluxJobSelected.has(job.id); + const thumbs = variants.map(variant => variant.image_path + ? `` + : `
                          ${variant.variant_index}${escapeHtml(fluxStatusLabel(variant.status))} · Seed ${variant.seed}
                          `).join(''); + const ledger = variants.map(variant => `
                          V${variant.variant_index}Seed ${variant.seed}${escapeHtml(variant.phase || variant.status)}${variant.prompt_id ? `${escapeHtml(String(variant.prompt_id).slice(0, 12))}${String(variant.prompt_id).length > 12 ? '…' : ''}` : 'Noch keine Prompt-ID'}
                          `).join(''); + const canCancel = FLUX_ACTIVE_JOB_STATUSES.has(job.status); + const canRetry = ['failed', 'cancelled', 'completed_with_errors'].includes(job.status) && !state.fluxJobArchiveMode; + const canArchive = !state.fluxJobArchiveMode && !canCancel; + return `
                          +
                          + + +
                          +
                          +
                          ${job.width} × ${job.height}${job.steps} Schritte${escapeHtml(job.style)}${job.variants_count} Variante${job.variants_count === 1 ? '' : 'n'}Seed ${job.seed_base}${job.seed_lock ? ' · Lock' : ''}

                          ${escapeHtml(job.message || '')}${job.error ? `
                          ${escapeHtml(job.error)}` : ''}

                          +
                          ${thumbs}
                          ${ledger}
                          +
                          + + ${canCancel ? `` : ''} + ${canRetry ? `` : ''} + ${canArchive ? `` : ''} + ${state.fluxJobArchiveMode ? `` : ''} +
                          +
                          +
                          `; + }).join(''); + container.querySelectorAll('[data-flux-job-select]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.fluxJobSelected.add(input.dataset.fluxJobSelect); else state.fluxJobSelected.delete(input.dataset.fluxJobSelect); + input.closest('.flux-job-card')?.classList.toggle('is-selected', input.checked); + updateFluxJobToolbar(); + })); + container.querySelectorAll('[data-flux-job-toggle]').forEach(button => button.addEventListener('click', () => { + const jobId = button.dataset.fluxJobToggle; + state.fluxJobManuallyToggled.add(jobId); + if (state.fluxJobExpanded.has(jobId)) state.fluxJobExpanded.delete(jobId); else state.fluxJobExpanded.add(jobId); + renderFluxJobList(); + })); + container.querySelectorAll('[data-flux-job-load]').forEach(button => button.addEventListener('click', () => { + const job = state.fluxJobs.find(entry => entry.id === button.dataset.fluxJobLoad); + if (job) { applyFluxJobToForm(job); setFluxStudioStatus('Prompt und Parameter wurden aus dem Verlauf geladen.', 'success'); } + })); + container.querySelectorAll('[data-flux-job-cancel]').forEach(button => button.addEventListener('click', async () => { + try { await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobCancel}/cancel`, {}); toast('Abbruch wurde angefordert.'); await loadFluxJobs(); } catch (error) { toast(`Abbruch fehlgeschlagen: ${error.message}`); } + })); + container.querySelectorAll('[data-flux-job-retry]').forEach(button => button.addEventListener('click', async () => { + try { const job = await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobRetry}/retry`, {}); state.fluxJobExpanded.add(job.id); toast('Auftrag wurde erneut eingereiht.'); await loadFluxJobs(); watchFluxJob(job.id); } catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); } + })); + container.querySelectorAll('[data-flux-job-archive]').forEach(button => button.addEventListener('click', () => archiveFluxJobs([button.dataset.fluxJobArchive]))); + container.querySelectorAll('[data-flux-job-restore]').forEach(button => button.addEventListener('click', () => restoreFluxJobs([button.dataset.fluxJobRestore]))); + container.querySelectorAll('[data-flux-job-delete]').forEach(button => button.addEventListener('click', () => deleteArchivedFluxJobs([button.dataset.fluxJobDelete]))); + container.querySelectorAll('[data-flux-job-image]').forEach(button => button.addEventListener('click', () => { + const [jobId, variantIndex] = button.dataset.fluxJobImage.split(':'); + const job = state.fluxJobs.find(entry => entry.id === jobId); + const variant = job?.variants?.find(entry => String(entry.variant_index) === variantIndex); + const item = fluxVariantToImage(job, variant); + if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); } + })); + updateFluxJobToolbar(); +} + +async function loadFluxJobs({ silent = false } = {}) { + const container = document.getElementById('flux-job-list'); + if (!silent && container && !state.fluxJobs.length) container.innerHTML = '
                          Job-Center wird geladen …
                          '; + try { + const filter = state.fluxJobArchiveMode ? '' : (document.getElementById('flux-job-filter')?.value || ''); + const params = new URLSearchParams({ page: String(state.fluxJobPage), page_size: String(state.fluxJobPageSize), archived: state.fluxJobArchiveMode ? '1' : '0' }); + if (filter) params.set('status', filter); + const result = await api('GET', `/api/flux-prompt/jobs?${params}`); + state.fluxJobs = result.jobs || []; + state.fluxJobSummary = result.summary || null; + state.fluxJobPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxJobs.length }; + state.fluxJobPage = Number(state.fluxJobPagination.page || 1); + state.fluxJobSelected = new Set([...state.fluxJobSelected].filter(id => state.fluxJobs.some(job => job.id === id))); + for (const job of state.fluxJobs) if (FLUX_ACTIVE_JOB_STATUSES.has(job.status) && !state.fluxJobManuallyToggled.has(job.id)) state.fluxJobExpanded.add(job.id); + renderFluxJobSummary(); renderFluxJobList(); renderFluxJobPagination(); updateFluxJobToolbar(); + if (!state.fluxJobArchiveMode) for (const job of state.fluxJobs.filter(entry => FLUX_ACTIVE_JOB_STATUSES.has(entry.status))) watchFluxJob(job.id); + } catch (error) { + if (!silent && container) container.innerHTML = `
                          Job-Center konnte nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +function bytesToGiB(value) { + const number = Number(value || 0); + return number > 0 ? `${(number / 1024 / 1024 / 1024).toFixed(1)} GB` : '–'; +} + +function renderFluxRuntimeInfo() { + const container = document.getElementById('flux-runtime-summary'); + if (!container) return; + const runtime = state.fluxRuntime; + if (!runtime) { container.innerHTML = '
                          Keine Systemdaten verfügbar.
                          '; return; } + const stats = runtime.system_stats || {}; + const system = stats.system || stats; + const devices = Array.isArray(stats.devices) ? stats.devices : Array.isArray(system.devices) ? system.devices : []; + const gpu = devices[0] || {}; + const queue = runtime.queue_summary || {}; + const historyCount = runtime.history ? Object.keys(runtime.history).length : 0; + container.innerHTML = ` +
                          +
                          ComfyUI${runtime.reachable ? 'Erreichbar' : 'Nicht erreichbar'}${escapeHtml(runtime.url || '')}
                          +
                          ComfyUI-Queue${queue.running || 0} aktiv · ${queue.pending || 0} wartend${historyCount} Historieneinträge geladen
                          +
                          GPU${escapeHtml(gpu.name || gpu.type || 'Nicht gemeldet')}${gpu.vram_total ? `${bytesToGiB(gpu.vram_free)} frei von ${bytesToGiB(gpu.vram_total)}` : 'VRAM-Daten nicht gemeldet'}
                          +
                          System${escapeHtml(system.os || system.system || system.python_version || 'ComfyUI-System')}${escapeHtml(system.comfyui_version || system.version || '')}
                          +
                          `; +} + +async function refreshFluxRuntimeInfo({ silent = false } = {}) { + const container = document.getElementById('flux-runtime-summary'); + if (!silent && container) container.innerHTML = '
                          ComfyUI-Warteschlange, Historie und GPU-Daten werden geladen …
                          '; + try { + state.fluxRuntime = await api('GET', '/api/flux-prompt/runtime'); + renderFluxRuntimeInfo(); + } catch (error) { + state.fluxRuntime = null; + if (container) container.innerHTML = `
                          Systemdaten nicht verfügbar: ${escapeHtml(error.message)}
                          `; + } +} + +async function useFluxImageInGenerator() { + const item = state.fluxStudioCurrent; + if (!item?.id) return; + try { + const result = await api('POST', `/api/flux-studio/images/${item.id}/copy-to-generator`, {}); + if (result.filename && !state.photos.includes(result.filename)) state.photos.push(result.filename); + renderPhotoPreview(); + switchToTab('generator'); + toast('Eine unabhängige Kopie wurde als Artikelfoto übernommen.'); + } catch (error) { toast(`Übernahme fehlgeschlagen: ${error.message}`); } +} + + +function aiProviderDisplayName(provider = state.aiProvider) { + return state.providers.find(entry => entry.id === provider)?.name || ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || 'AI'; +} + +function openFluxPromptLab() { + const prompt = document.getElementById('flux-studio-prompt'); + if (!prompt?.value.trim()) return toast('Bitte zuerst einen Prompt eingeben.'); + const modal = document.getElementById('flux-prompt-lab'); + document.getElementById('flux-prompt-lab-original').value = prompt.value.trim(); + document.getElementById('flux-prompt-lab-result').value = ''; + document.getElementById('flux-prompt-lab-notes').textContent = ''; + document.getElementById('flux-prompt-lab-status').textContent = 'Bereit für lokale Strukturierung oder AI-Veredelung.'; + document.getElementById('flux-prompt-lab-provider').textContent = `${aiProviderDisplayName()}${state.aiModel ? ` · ${state.aiModel}` : ' · Auto'}`; + modal?.classList.remove('hidden'); + modal?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + setTimeout(() => document.getElementById('flux-prompt-lab-original')?.focus(), 40); +} + +function closeFluxPromptLab() { + const modal = document.getElementById('flux-prompt-lab'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + if (document.getElementById('smart-copy-modal')?.classList.contains('hidden')) document.body.classList.remove('modal-open'); +} + +function setupFluxPromptLab() { + document.querySelectorAll('[data-flux-prompt-lab-close]').forEach(button => button.addEventListener('click', closeFluxPromptLab)); + document.getElementById('flux-prompt-lab-local')?.addEventListener('click', () => { + const original = document.getElementById('flux-prompt-lab-original')?.value || ''; + const optimized = optimizeFluxPromptLocally(original); + if (!optimized) return toast('Bitte zuerst einen Prompt eingeben.'); + document.getElementById('flux-prompt-lab-result').value = optimized; + document.getElementById('flux-prompt-lab-status').textContent = 'Lokal strukturiert · keine API-Anfrage.'; + document.getElementById('flux-prompt-lab-notes').textContent = 'Motiv, Licht, Komposition und Qualitätsmerkmale wurden in eine klarere Reihenfolge gebracht.'; + }); + document.getElementById('flux-prompt-lab-ai')?.addEventListener('click', async event => { + const original = document.getElementById('flux-prompt-lab-original')?.value.trim() || ''; + if (!original) return toast('Bitte zuerst einen Prompt eingeben.'); + const button = event.currentTarget; + const status = document.getElementById('flux-prompt-lab-status'); + button.disabled = true; + button.textContent = `${aiProviderDisplayName()} arbeitet …`; + status.textContent = `Prompt wird mit ${aiProviderDisplayName()} veredelt …`; + try { + const payload = { + prompt: original, + provider: state.aiProvider, + model: state.aiModel || undefined, + goal: document.getElementById('flux-prompt-lab-goal')?.value || 'product', + preserve_subject: document.getElementById('flux-prompt-lab-preserve')?.checked !== false, + avoid_text: document.getElementById('flux-prompt-lab-avoid-text')?.checked !== false, + }; + const result = await api('POST', '/api/flux-prompt/improve', payload); + document.getElementById('flux-prompt-lab-result').value = result.improved_prompt || ''; + document.getElementById('flux-prompt-lab-notes').textContent = result.notes || 'Prompt wurde für FLUX veredelt.'; + status.textContent = `Fertig · ${aiProviderDisplayName(result.provider)}${result.model ? ` · ${result.model}` : ''}`; + } catch (error) { + status.textContent = `AI-Veredelung fehlgeschlagen: ${error.message}`; + toast(`Prompt Lab: ${error.message}`); + } finally { + button.disabled = false; + button.textContent = 'Mit AI veredeln'; + } + }); + document.getElementById('flux-prompt-lab-apply')?.addEventListener('click', () => { + const result = document.getElementById('flux-prompt-lab-result')?.value.trim(); + if (!result) return toast('Erzeuge zuerst einen optimierten Prompt.'); + const prompt = document.getElementById('flux-studio-prompt'); + prompt.value = result.slice(0, 2000); + prompt.dispatchEvent(new Event('input', { bubbles: true })); + prompt.focus(); + closeFluxPromptLab(); + toast('Optimierter Prompt wurde übernommen.'); + }); +} + +function setupFluxStudio() { + const prompt = document.getElementById('flux-studio-prompt'); + if (!prompt) return; + const updateCount = () => { const count = document.getElementById('flux-studio-prompt-count'); if (count) count.textContent = String(prompt.value.length); }; + prompt.addEventListener('input', () => { updateCount(); refreshFluxPromptQuality(); }); + updateCount(); + refreshFluxPromptQuality(); + document.getElementById('flux-studio-optimize-prompt')?.addEventListener('click', openFluxPromptLab); + setupFluxPromptLab(); + document.querySelectorAll('[data-flux-profile]').forEach(button => button.addEventListener('click', () => applyFluxProfile(button.dataset.fluxProfile))); + document.querySelectorAll('[data-flux-variants]').forEach(button => button.addEventListener('click', () => setFluxVariantCount(button.dataset.fluxVariants))); + document.getElementById('flux-studio-format')?.addEventListener('change', () => applyFluxProfile('custom')); + document.getElementById('flux-studio-steps')?.addEventListener('change', () => applyFluxProfile('custom')); + let savedFluxProfile = 'balanced'; try { savedFluxProfile = localStorage.getItem('vendoo-flux-profile') || savedFluxProfile; } catch {} + let savedFluxVariants = 1; try { savedFluxVariants = Number(localStorage.getItem('vendoo-flux-variants') || 1); } catch {} + applyFluxProfile(savedFluxProfile, { persist: false }); + setFluxVariantCount(savedFluxVariants, { persist: false }); + renderFluxStudioPreview(null); + const initialVariantResults = document.getElementById('flux-studio-variant-results'); + if (initialVariantResults) { initialVariantResults.innerHTML = ''; initialVariantResults.classList.add('hidden'); } + document.querySelectorAll('[data-flux-template]').forEach(button => button.addEventListener('click', () => { + const template = FLUX_PROMPT_TEMPLATES[button.dataset.fluxTemplate]; + if (!template) return; + prompt.value = prompt.value.trim() ? `${prompt.value.trim()}, ${template}` : template; + updateCount(); refreshFluxPromptQuality(); prompt.focus(); + })); + document.getElementById('flux-studio-random-seed')?.addEventListener('click', () => { + document.getElementById('flux-studio-seed').value = String(Math.floor(Math.random() * 2147483647)); + }); + document.getElementById('flux-studio-generate')?.addEventListener('click', () => runFluxStudioGeneration()); + document.getElementById('flux-studio-reset')?.addEventListener('click', () => { + if (state.fluxStudioBusy && !window.confirm('Ein FLUX-Auftrag läuft noch. Nur den sichtbaren Arbeitsbereich zurücksetzen? Der Auftrag läuft im Job-Center weiter.')) return; + resetFluxStudioWorkspace({ clearSavedDefaults: true, announce: true }); + }); + document.getElementById('flux-studio-refresh-engine')?.addEventListener('click', async () => { await refreshFluxStudioEngineStatus(); await refreshFluxRuntimeInfo(); }); + document.getElementById('flux-runtime-refresh')?.addEventListener('click', () => refreshFluxRuntimeInfo()); + document.getElementById('flux-job-refresh')?.addEventListener('click', () => loadFluxJobs()); + document.getElementById('flux-job-filter')?.addEventListener('change', () => { state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-page-size')?.addEventListener('change', event => { state.fluxJobPageSize = Number(event.target.value) || 10; state.fluxJobPage = 1; loadFluxJobs(); }); + document.getElementById('flux-job-view-active')?.addEventListener('click', () => { state.fluxJobArchiveMode = false; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-view-archive')?.addEventListener('click', () => { state.fluxJobArchiveMode = true; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-prev')?.addEventListener('click', () => { if (state.fluxJobPage > 1) { state.fluxJobPage -= 1; loadFluxJobs(); } }); + document.getElementById('flux-job-next')?.addEventListener('click', () => { if (state.fluxJobPage < Number(state.fluxJobPagination.total_pages || 1)) { state.fluxJobPage += 1; loadFluxJobs(); } }); + document.getElementById('flux-job-select-page')?.addEventListener('change', event => { for (const job of state.fluxJobs) { if (event.target.checked) state.fluxJobSelected.add(job.id); else state.fluxJobSelected.delete(job.id); } renderFluxJobList(); }); + document.getElementById('flux-job-archive-selected')?.addEventListener('click', () => archiveFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-restore-selected')?.addEventListener('click', () => restoreFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-delete-selected')?.addEventListener('click', () => deleteArchivedFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-delete-all-archive')?.addEventListener('click', () => deleteArchivedFluxJobs([], true)); + document.getElementById('flux-job-collapse-all')?.addEventListener('click', () => { + for (const job of state.fluxJobs) state.fluxJobManuallyToggled.add(job.id); + state.fluxJobExpanded.clear(); + renderFluxJobList(); + }); + const jobCenterToggle = document.getElementById('flux-job-center-toggle'); + const jobCenterBody = document.getElementById('flux-job-center-body'); + const applyJobCenterState = () => { + jobCenterBody?.classList.toggle('hidden', state.fluxJobCenterCollapsed); + if (jobCenterToggle) { + jobCenterToggle.setAttribute('aria-expanded', String(!state.fluxJobCenterCollapsed)); + jobCenterToggle.textContent = state.fluxJobCenterCollapsed ? 'Aufträge aufklappen' : 'Aufträge einklappen'; + } + }; + try { state.fluxJobCenterCollapsed = localStorage.getItem('vendoo-flux-job-center-collapsed') === '1'; } catch {} + applyJobCenterState(); + jobCenterToggle?.addEventListener('click', () => { + state.fluxJobCenterCollapsed = !state.fluxJobCenterCollapsed; + try { localStorage.setItem('vendoo-flux-job-center-collapsed', state.fluxJobCenterCollapsed ? '1' : '0'); } catch {} + applyJobCenterState(); + }); + document.getElementById('flux-job-clear-queue')?.addEventListener('click', async () => { + if (!window.confirm('Alle noch wartenden FLUX-Varianten aus der Warteschlange entfernen? Laufende Varianten bleiben unberührt.')) return; + try { const result = await api('DELETE', '/api/flux-prompt/jobs/queue'); toast(`${result.cleared_variants || 0} wartende Variante(n) entfernt.`); await loadFluxJobs(); } + catch (error) { toast(`Warteschlange konnte nicht geleert werden: ${error.message}`); } + }); + document.getElementById('flux-studio-refresh-history')?.addEventListener('click', () => loadFluxStudioHistory()); + document.getElementById('flux-history-page-size')?.addEventListener('change', event => { state.fluxHistoryPageSize = Number(event.target.value) || 5; state.fluxHistoryPage = 1; loadFluxStudioHistory(); }); + document.getElementById('flux-history-prev')?.addEventListener('click', () => { if (state.fluxHistoryPage > 1) { state.fluxHistoryPage -= 1; loadFluxStudioHistory(); } }); + document.getElementById('flux-history-next')?.addEventListener('click', () => { if (state.fluxHistoryPage < Number(state.fluxHistoryPagination.total_pages || 1)) { state.fluxHistoryPage += 1; loadFluxStudioHistory(); } }); + document.getElementById('flux-studio-favorites-only')?.addEventListener('change', () => { state.fluxHistoryPage = 1; loadFluxStudioHistory(); }); + document.getElementById('flux-studio-edit')?.addEventListener('click', () => openPhotoEditor(0, 'flux')); + document.getElementById('flux-studio-similar')?.addEventListener('click', () => createSimilarFluxJob()); + document.getElementById('flux-studio-favorite')?.addEventListener('click', () => toggleFluxFavorite()); + document.getElementById('flux-studio-use-in-generator')?.addEventListener('click', useFluxImageInGenerator); + document.getElementById('flux-studio-delete')?.addEventListener('click', () => deleteFluxStudioImage()); + document.getElementById('open-flux-studio-page-btn')?.addEventListener('click', () => switchToTab('flux-studio')); + document.getElementById('flux-studio-start-engine')?.addEventListener('click', async () => { + const button = document.getElementById('flux-studio-start-engine'); + if (button) button.disabled = true; + try { + const profile = document.getElementById('setting-flux-runtime-profile')?.value || 'auto'; + const result = await api('POST', '/api/local-image/start', { profile }); + toast(result.message || 'FLUX wird gestartet.'); + await waitForFluxEngineReady(); + setFluxStudioStatus('FLUX ist aktiv und bereit.', 'success'); + await refreshFluxRuntimeInfo(); + toast('FLUX ist aktiv und bereit.'); + } catch (error) { + setFluxStudioStatus(`FLUX-Start fehlgeschlagen: ${error.message}`, 'error'); + toast(`FLUX-Start fehlgeschlagen: ${error.message}`); + } finally { + if (button) button.disabled = false; + } + }); + document.getElementById('flux-studio-stop-engine')?.addEventListener('click', async () => { + try { const result = await api('POST', '/api/local-image/stop', {}); toast(result.message || 'FLUX wird gestoppt.'); setTimeout(refreshFluxStudioEngineStatus, 1200); } + catch (error) { toast(`FLUX-Stopp fehlgeschlagen: ${error.message}`); } + }); + refreshFluxStudioEngineStatus(); + loadFluxStudioHistory(); + loadFluxJobs(); + refreshFluxRuntimeInfo(); + const poller = window.setInterval(() => { + const page = document.getElementById('flux-studio'); + if (page?.classList.contains('active')) { + loadFluxJobs({ silent: true }); + refreshFluxRuntimeInfo({ silent: true }); + } + }, 5000); + window.addEventListener('beforeunload', () => window.clearInterval(poller), { once: true }); +} + + +function updateAiModeControls(scope = 'generator') { + const promptButton = document.getElementById(`${scope}-ai-model-prompt-btn`); + if (promptButton) { + promptButton.disabled = false; + promptButton.title = 'Optionale Zusatzanweisung'; + } +} + +async function runGeneratorAiModelPhotos() { + if (!state.photos.length) return toast('Bitte zuerst Produktfotos hochladen'); + const button = document.getElementById('generator-ai-model-btn'); + button.disabled = true; + setAiModelStatus('generator-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const draft = getAiPromptDraft('generator'); + const payload = { ...getGeneratorAiModelPayload(), custom_prompt: draft.customPrompt || '', preserve_logos: draft.preserveLogos ? '1' : '0' }; + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('generator-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelJobProgress('generator-ai-model-status', job); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + state.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('generator', state.aiModelPhotos); + const providerLabel = state.aiModelPhotos[0]?.provider_model || state.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('generator-ai-model-status', `${state.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + } catch (err) { + setAiModelStatus('generator-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + button.disabled = false; + } +} + +async function runDetailAiModelPhotos() { + if (!detailEditorState?.photos?.length) return toast('Bitte zuerst Produktfotos hinzufügen'); + const button = document.getElementById('detail-ai-model-btn'); + if (button) button.disabled = true; + setAiModelStatus('detail-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const draft = getAiPromptDraft('detail'); + const payload = { + listing_id: detailEditorState.listing.id, + photos: [...detailEditorState.photos], + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || detailEditorState.listing.title || '', + category: detailEditorState.categoryPicker?.getValue?.() || detailEditorState.listing.category || '', + brand: document.getElementById('detail-brand')?.value || detailEditorState.listing.brand || '', + color: document.getElementById('detail-color')?.value || detailEditorState.listing.color || '', + size: document.getElementById('detail-size')?.value || detailEditorState.listing.size || '', + condition: document.getElementById('detail-condition')?.value || detailEditorState.listing.condition || '', + seller_notes: document.getElementById('detail-seller-notes')?.value || detailEditorState.listing.seller_notes || '', + mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('detail-ai-model-variants')?.value || '1', 10), + custom_prompt: draft.customPrompt || '', + preserve_logos: draft.preserveLogos ? '1' : '0', + }; + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('detail-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelJobProgress('detail-ai-model-status', job); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + detailEditorState.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + const providerLabel = detailEditorState.aiModelPhotos[0]?.provider_model || detailEditorState.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + loadDetailAiModelGenerations(detailEditorState.listing.id); + } catch (err) { + setAiModelStatus('detail-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + if (button) button.disabled = false; + } +} + +async function loadDetailAiModelPhotos(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-photos`); + detailEditorState.aiModelPhotos = items || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + if (detailEditorState.aiModelPhotos.length) { + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} vorhandene AI-Bilder geladen.`, 'neutral'); + } + } catch {} +} + +function renderDetailAiModelGenerations(generations = []) { + const wrap = document.getElementById('detail-ai-model-history'); + if (!wrap) return; + if (!generations.length) { + wrap.innerHTML = '
                          Noch keine AI-Bildhistorie vorhanden.
                          '; + return; + } + wrap.innerHTML = generations.slice(0, 6).map(gen => ` +
                          +
                          ${escapeHtml((gen.mode || 'model').replace('_', ' '))}${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder
                          +
                          + ${(gen.photos || []).slice(0, 3).map(photo => ``).join('')} +
                          +
                          + `).join(''); + wrap.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => { + const file = button.dataset.aiModelAddOne; + if (!detailEditorState.photos.includes(file)) detailEditorState.photos.push(file); + renderDetailPhotos(); + toast('AI-Bild aus Historie hinzugefügt'); + })); +} + +async function loadDetailAiModelGenerations(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-generations`); + detailEditorState.aiModelGenerations = items || []; + renderDetailAiModelGenerations(detailEditorState.aiModelGenerations); + } catch {} +} + + + +// --- Tab titles for topbar --- +const TAB_TITLES = { + today: 'Studio Flow', + dashboard: 'Dashboard', + generator: 'Neuer Artikel', + templates: 'Vorlagen', + history: 'Artikel', + 'quality-center': 'Qualitätscenter', + publish: 'Publish', + trash: 'Papierkorb', + inventory: 'Lager', + fees: 'Gebühren', + admin: 'Admin / Benutzer & System', + 'flux-studio': 'FLUX Studio', + 'media-library': 'Bildergalerie', + 'image-factory': 'Bildproduktion', + extensions: 'Extensions', + settings: 'Einstellungen', +}; + +const TAB_KICKERS = { + today: 'Arbeitsplatz', + dashboard: 'Analytics', + generator: 'Generator', + templates: 'Inhalte', + history: 'Übersicht', + 'quality-center': 'Artikelprüfung', + publish: 'Control Center', + trash: 'Wiederherstellung', + inventory: 'Bestand', + fees: 'Kalkulation', + admin: 'Verwaltung', + 'flux-studio': 'Bildgenerator', + 'media-library': 'Medienverwaltung', + 'image-factory': 'Batch Image Factory', + extensions: 'Browser-Integration', + settings: 'Konfiguration', +}; + +function setMobileSidebarOpen(open) { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const toggle = document.getElementById('sidebar-toggle'); + if (!sidebar || !overlay) return; + sidebar.classList.toggle('open', !!open); + overlay.classList.toggle('show', !!open); + document.body.classList.toggle('mobile-nav-open', !!open); + toggle?.setAttribute('aria-expanded', open ? 'true' : 'false'); + sidebar.setAttribute('aria-hidden', (!open && window.matchMedia('(max-width: 768px)').matches) ? 'true' : 'false'); + if (open) { + window.setTimeout(() => sidebar.querySelector('.nav-item.active, .nav-item')?.focus(), 60); + } else { + toggle?.focus({ preventScroll: true }); + } +} + +function closeMobileSidebar({ restoreFocus = false } = {}) { + const toggle = document.getElementById('sidebar-toggle'); + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + sidebar?.classList.remove('open'); + overlay?.classList.remove('show'); + document.body.classList.remove('mobile-nav-open'); + toggle?.setAttribute('aria-expanded', 'false'); + if (sidebar && window.matchMedia('(max-width: 768px)').matches) sidebar.setAttribute('aria-hidden', 'true'); + if (restoreFocus) toggle?.focus({ preventScroll: true }); +} + +function switchToTab(tabId, adminTab) { + if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true }); + document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + const section = document.getElementById(tabId); + if (section) section.classList.add('active'); + window.requestAnimationFrame(applyResponsiveContract); + + // Highlight the correct sidebar item + const selector = adminTab + ? `.nav-item[data-tab="${tabId}"][data-admin-tab="${adminTab}"]` + : `.nav-item[data-tab="${tabId}"]:not([data-admin-tab])`; + const activeBtn = document.querySelector(selector) + || document.querySelector(`.nav-item[data-tab="${tabId}"]`); + if (activeBtn) activeBtn.classList.add('active'); + + // Update topbar title + const titleEl = document.getElementById('topbar-title'); + if (titleEl) titleEl.textContent = TAB_TITLES[tabId] || tabId; + const kickerEl = document.getElementById('topbar-kicker'); + if (kickerEl) kickerEl.textContent = TAB_KICKERS[tabId] || ''; + + // Switch admin sub-tab if specified + if (tabId === 'admin' && adminTab) { + switchAdminTab(adminTab); + if (adminTab === 'users') loadAdminUsers(); + if (adminTab === 'roles') loadRoleMatrix(); + if (adminTab === 'sessions') loadAdminSessions(); + if (adminTab === 'smtp') loadSmtpSettings(); + if (adminTab === 'system') { loadSystemInfo(); loadOperationsCenter(); } + if (adminTab === 'security') loadSecurityCenter(); + if (adminTab === 'audit') loadAdminAudit(); + } + + // Load data for the tab + if (tabId === 'today') loadToday(); + if (tabId === 'dashboard') loadAnalyticsDashboard(); + if (tabId === 'history') { setHistoryDetailMode(false); loadHistory(); } + if (tabId === 'quality-center') loadQualityCenter(); + if (tabId === 'publish') loadPublishList(); + if (tabId === 'trash') loadTrash(); + if (tabId === 'templates') renderTemplateList(); + if (tabId === 'inventory') loadInventory(); + if (tabId === 'fees') updateFeeCalculator(); + if (tabId === 'flux-studio') { refreshFluxStudioEngineStatus(); loadFluxStudioHistory(); loadFluxJobs({ silent: true }); } + if (tabId === 'media-library') loadMediaLibrary(); + if (tabId === 'image-factory') loadImageFactory(); + if (tabId === 'extensions') loadExtensions(); + + // Close mobile sidebar + closeMobileSidebar(); +} + +// --- Tabs --- +function setupTabs() { + document.querySelectorAll('.nav-item').forEach(btn => { + btn.addEventListener('click', () => { + switchToTab(btn.dataset.tab, btn.dataset.adminTab); + }); + }); + + // Mobile sidebar toggle + document.getElementById('sidebar-toggle')?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const open = !document.getElementById('sidebar')?.classList.contains('open'); + setMobileSidebarOpen(open); + }); + document.getElementById('sidebar-close')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.getElementById('sidebar-overlay')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && document.getElementById('sidebar')?.classList.contains('open')) { + closeMobileSidebar({ restoreFocus: true }); + } + }); + window.addEventListener('resize', debounce(() => { + const mobile = window.matchMedia('(max-width: 768px)').matches; + const sidebar = document.getElementById('sidebar'); + if (!mobile) { + closeMobileSidebar(); + sidebar?.setAttribute('aria-hidden', 'false'); + } else if (!sidebar?.classList.contains('open')) { + sidebar?.setAttribute('aria-hidden', 'true'); + } + }, 120)); + if (window.matchMedia('(max-width: 768px)').matches) document.getElementById('sidebar')?.setAttribute('aria-hidden', 'true'); + + // Topbar "Neuer Artikel" button + document.getElementById('topbar-new-btn')?.addEventListener('click', () => { + switchToTab('generator'); + }); + + // Topbar search button — focus search in history + document.getElementById('topbar-search-btn')?.addEventListener('click', () => { + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + }); + + document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + } + }); + + document.addEventListener('click', event => { + const target = event.target.closest('[data-tab-target]'); + if (target) switchToTab(target.dataset.tabTarget); + }); +} + +// --- Platforms --- +function renderPlatformButtons() { + const c = document.getElementById('platform-buttons'); + c.innerHTML = ''; + for (const p of state.platforms) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.platform ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.platform = p.id; + updateGeneratorReadiness(); + refreshGeneratorPlatformPreview(); + if (!document.getElementById('meta-section').classList.contains('hidden') && metaCategoryPicker) { + const currentVal = metaCategoryPicker.getValue(); + metaCategoryPicker = createCategoryPicker('meta-category-picker', p.id, null); + if (currentVal) { + const name = currentVal.split(' > ').pop(); + api('GET', `/api/categories/${p.id}/match?q=${encodeURIComponent(name)}`).then(matches => { + if (matches.length && matches[0].score >= 50) metaCategoryPicker.setValue(matches[0].pathStr); + }).catch(() => {}); + } + } + }); + c.appendChild(btn); + } +} + +// --- Providers --- +function renderProviderButtons() { + const c = document.getElementById('provider-buttons'); + c.innerHTML = ''; + for (const p of state.providers) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.aiProvider ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.aiProvider = p.id; + state.aiModel = ''; + updateModelDropdown(); + }); + c.appendChild(btn); + } + updateModelDropdown(); +} + +function updateModelDropdown() { + const wrap = document.getElementById('model-select-wrap'); + const sel = document.getElementById('model-select'); + const prov = state.providers.find(p => p.id === state.aiProvider); + if (prov?.hasModels && prov.models?.length) { + const isOpenRouter = prov.id === 'openrouter'; + const options = isOpenRouter + ? `` + prov.models.map(m => ``).join('') + : prov.models.map(m => ``).join(''); + sel.innerHTML = options; + state.aiModel = isOpenRouter ? '' : prov.models[0].id; + sel.onchange = () => { state.aiModel = sel.value; }; + wrap.classList.remove('hidden'); + } else { + state.aiModel = ''; + wrap.classList.add('hidden'); + } +} + +// --- Upload --- +function setupUpload() { + const dz = document.getElementById('dropzone'); + const fi = document.getElementById('file-input'); + dz.addEventListener('click', () => fi.click()); + dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); }); + dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); + dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); + fi.addEventListener('change', () => { handleFiles(fi.files); fi.value = ''; }); + enablePhotoDragSort(); +} + +async function handleFiles(files) { + const available = Math.max(0, 10 - state.photos.length); + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/')); + if (!available) { toast('Maximal 10 Fotos pro Listing'); return; } + const accepted = imageFiles.slice(0, available); + if (imageFiles.length > available) toast(`Es wurden nur ${available} weitere Fotos übernommen`); + const fd = new FormData(); + fd.append('platform', state.platform || 'allgemein'); + for (const file of accepted) fd.append('photos', file); + if (!fd.has('photos')) return; + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(state.platform || 'allgemein')}`, { + method: 'POST', + body: fd, + headers: { 'X-CSRF-Token': getCsrf() }, + credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status})`); + state.photos.push(...(data.filenames || [])); + renderPhotoPreviews(); + updateGenerateButton(); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function renderPhotoPreviews() { + renderPhotoPreview(); +} + +function renderPhotoPreview() { + const c = document.getElementById('photo-preview'); + c.innerHTML = ''; + for (let i = 0; i < state.photos.length; i++) { + const div = document.createElement('div'); + div.className = 'thumb'; + div.draggable = true; + div.dataset.index = String(i + 1); + const safeUrl = photoUrl(state.photos[i]); + div.innerHTML = `Foto ${i + 1}`; + div.querySelector('img').addEventListener('click', e => { + e.stopPropagation(); + if (i > 0) { + const selected = state.photos.splice(i, 1)[0]; + state.photos.unshift(selected); + renderPhotoPreview(); + toast('Coverfoto geändert'); + } + }); + div.querySelector('.remove-photo').addEventListener('click', e => { + e.stopPropagation(); + state.photos.splice(i, 1); + renderPhotoPreview(); + updateGenerateButton(); + }); + div.querySelector('.edit-photo').addEventListener('click', e => { + e.stopPropagation(); + openPhotoEditor(i); + }); + c.appendChild(div); + } + document.getElementById('image-tools').classList.toggle('hidden', state.photos.length === 0); + updateGeneratorPhotoStage(); +} + +function updateGenerateButton() { + const ready = state.photos.length > 0; + document.getElementById('generate-btn').disabled = !ready; + if (!ready) { + state.aiModelPhotos = []; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + } else if (!state.aiModelPhotos.length) { + setAiModelStatus('generator-ai-model-status', 'Bereit für sichere AI-Model-Fotos.', 'neutral'); + } + updateGeneratorReadiness(); +} + +// --- Image tools --- +function setupImageTools() { + document.getElementById('advanced-image-editor-btn')?.addEventListener('click', () => { + if (!state.photos.length) return toast('Bitte zuerst mindestens ein Foto hochladen.'); + openPhotoEditor(0, 'generator'); + }); +} + +// --- Generate --- +function setupGenerate() { + document.getElementById('generate-btn').addEventListener('click', generateListing); + document.getElementById('reset-generator-btn').addEventListener('click', resetGenerator); + document.getElementById('language-select').addEventListener('change', e => { state.language = e.target.value; updateGeneratorReadiness(); }); + document.getElementById('variants-select').addEventListener('change', e => { state.variants = parseInt(e.target.value); }); + document.getElementById('template-select').addEventListener('change', e => applyGeneratorTemplate(e.target.value)); + document.getElementById('generator-ai-model-btn')?.addEventListener('click', runGeneratorAiModelPhotos); + document.getElementById('generator-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('generator')); + document.getElementById('generator-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('generator')); + updateAiModeControls('generator'); +} + +let generatorRenderSequence = 0; + +function getGeneratorListingForHtml() { + const tags = (document.getElementById('result-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: state.platform, + title: document.getElementById('result-title')?.value || '', + description: editors.result ? editors.result.getText().trim() : '', + description_html: getEditorHtml(editors.result), + tags, + brand: document.getElementById('meta-brand')?.value || null, + category: metaCategoryPicker?.getValue?.() || null, + size: document.getElementById('meta-size')?.value || null, + color: document.getElementById('meta-color')?.value || null, + condition: document.getElementById('meta-condition')?.value || null, + }; +} + +const refreshGeneratorPlatformPreview = debounce(async () => { + if (!state.currentListing || document.getElementById('result-section')?.classList.contains('hidden')) return; + const sequence = ++generatorRenderSequence; + setHtmlSyncStatus('generator-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const listing = getGeneratorListingForHtml(); + const data = await api('POST', '/api/html-render', { platform: state.platform, listing }); + if (sequence !== generatorRenderSequence) return; + state.currentHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + } catch (err) { + if (sequence !== generatorRenderSequence) return; + setSandboxPreview(document.getElementById('html-preview'), getEditorHtml(editors.result)); + setHtmlSyncStatus('generator-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupGeneratorHtmlSync() { + const editor = editors.result; + const source = document.getElementById('html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.generatorLock) return; + source.value = getEditorHtml(editor); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }); + + const applySourceToEditor = debounce(() => { + htmlSyncState.generatorLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.generatorLock = false; + source.value = safe; + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('generator-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('html-preview'), sanitizeRichHtmlClient(source.value)); + applySourceToEditor(); + }); +} + + +function setHtmlView(scope, view) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + const active = button.dataset.htmlView === view; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + const root = scope === 'generator' ? document.getElementById('html-preview-wrap') : document.querySelector('.detail-platform-workbench'); + root?.querySelectorAll('.html-switch-view').forEach(panel => panel.classList.toggle('is-active', panel.dataset.htmlPanel === view)); +} + +function bindHtmlViewTabs(scope) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + if (button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', () => setHtmlView(scope, button.dataset.htmlView)); + }); +} + +function setupHtmlViewSwitches() { + bindHtmlViewTabs('generator'); + setHtmlView('generator', 'preview'); +} + +function setupMobileUpload() { + document.getElementById('generator-mobile-upload')?.addEventListener('click', () => openMobileUpload('generator')); + document.getElementById('mobile-upload-close')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-done')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-new-session')?.addEventListener('click', () => openMobileUpload(mobileUploadState.target?.context || 'generator', mobileUploadState.target?.listingId, true)); + document.getElementById('mobile-upload-copy-link')?.addEventListener('click', async () => { + const input = document.getElementById('mobile-upload-link'); + if (!input?.value) return; + await navigator.clipboard.writeText(input.value); + toast('Upload-Link kopiert'); + }); + document.getElementById('mobile-upload-overlay')?.addEventListener('click', event => { + if (event.target.id === 'mobile-upload-overlay') closeMobileUpload(); + }); +} + +async function openMobileUpload(context = 'generator', listingId = null, renew = false) { + if (renew && mobileUploadState.session?.id) { + try { await api('DELETE', `/api/mobile-upload-sessions/${mobileUploadState.session.id}`); } catch {} + } + stopMobileUploadPolling(); + mobileUploadState.target = { context, listingId: listingId || null }; + mobileUploadState.imported = new Set(context === 'listing' ? (detailEditorState?.photos || []) : state.photos); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.remove('hidden'); + overlay?.setAttribute('aria-hidden', 'false'); + setMobileUploadDialogState('Link wird vorbereitet …', 'working'); + try { + const session = await api('POST', '/api/mobile-upload-sessions', { context, listing_id: listingId || null }); + mobileUploadState.session = session; + const link = document.getElementById('mobile-upload-link'); + if (link) link.value = session.url; + const qrCanvas = document.getElementById('mobile-upload-qr'); + if (typeof QRious !== 'undefined' && qrCanvas) new QRious({ element: qrCanvas, value: session.url, size: 240, level: 'M', foreground: '#171714', background: '#ffffff' }); + const expiry = new Date(session.expires_at); + document.getElementById('mobile-upload-expiry').textContent = `Gültig bis ${expiry.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + const localOnly = /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(session.url); + const networkNote = document.getElementById('mobile-upload-network-note'); + if (networkNote) { + networkNote.textContent = localOnly + ? 'Keine LAN-IP erkannt. Prüfe Windows-Firewall und Netzwerkadapter oder setze PUBLIC_BASE_URL manuell.' + : `Handy und Vendoo-PC müssen im selben Netzwerk sein. Erkannte Adresse: ${session.base_url || session.url.split('/mobile-upload/')[0]}`; + } + setMobileUploadDialogState(localOnly ? 'LAN-Adresse prüfen' : 'Bereit zum Scannen', localOnly ? 'warning' : 'ready'); + updateMobileUploadCount([]); + mobileUploadState.pollTimer = window.setInterval(pollMobileUploadSession, 1500); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + } +} + +function setMobileUploadDialogState(text, tone) { + const element = document.getElementById('mobile-upload-session-state'); + if (!element) return; + element.lastChild.textContent = text; + element.dataset.tone = tone; +} + +function updateMobileUploadCount(files) { + const count = document.getElementById('mobile-upload-file-count'); + if (count) count.textContent = `${files.length} ${files.length === 1 ? 'Bild' : 'Bilder'} empfangen`; +} + +async function pollMobileUploadSession() { + const session = mobileUploadState.session; + if (!session?.id) return; + try { + const data = await api('GET', `/api/mobile-upload-sessions/${session.id}`); + const files = Array.isArray(data.files) ? data.files : []; + const fresh = files.filter(file => !mobileUploadState.imported.has(file)); + fresh.forEach(file => mobileUploadState.imported.add(file)); + if (fresh.length) { + if (mobileUploadState.target?.context === 'listing' && detailEditorState) { + detailEditorState.photos = [...new Set([...detailEditorState.photos, ...fresh])].slice(0, 10); + renderDetailPhotos(); + } else { + state.photos = [...new Set([...state.photos, ...fresh])].slice(0, 10); + renderPhotoPreviews(); + updateGeneratorReadiness(); + } + toast(`${fresh.length} ${fresh.length === 1 ? 'Bild empfangen' : 'Bilder empfangen'}`); + } + updateMobileUploadCount(files); + if (files.length) setMobileUploadDialogState('Bilder werden live übertragen', 'ready'); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + stopMobileUploadPolling(); + } +} + +function stopMobileUploadPolling() { + if (mobileUploadState.pollTimer) window.clearInterval(mobileUploadState.pollTimer); + mobileUploadState.pollTimer = null; +} + +function closeMobileUpload() { + stopMobileUploadPolling(); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.add('hidden'); + overlay?.setAttribute('aria-hidden', 'true'); +} + +function setupGeneratorStudio() { + const notes = document.getElementById('generator-seller-notes'); + notes?.addEventListener('input', () => { + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes.value.length} / 500`; + }); + document.getElementById('result-title')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('result-tags')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('meta-price')?.addEventListener('input', debounce(updateGeneratorFeePreview, 250)); + document.getElementById('generator-download-photos')?.addEventListener('click', () => downloadPublishPhotos(state.photos)); + ['meta-brand', 'meta-size', 'meta-color', 'meta-condition'].forEach(id => { + document.getElementById(id)?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + }); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +function applyGeneratorTemplate(value) { + const tpl = state.templates.find(t => t.id === parseInt(value)); + const notes = document.getElementById('generator-seller-notes'); + const tags = document.getElementById('generator-template-tags'); + if (!tpl) { + if (notes) notes.value = ''; + if (tags) { tags.innerHTML = ''; tags.classList.add('hidden'); } + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = '0 / 500'; + return; + } + if (tpl.platform) { + state.platform = tpl.platform; + renderPlatformButtons(); + } + if (tpl.language) { + state.language = tpl.language; + document.getElementById('language-select').value = tpl.language; + } + if (notes) notes.value = (tpl.seller_notes || '').slice(0, 500); + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes?.value.length || 0} / 500`; + const defaultTags = Array.isArray(tpl.default_tags) ? tpl.default_tags : []; + if (tags) { + tags.innerHTML = defaultTags.map(tag => `${escapeHtml(tag)}`).join(''); + tags.classList.toggle('hidden', defaultTags.length === 0); + } + updateGeneratorReadiness(); +} + +function updateGeneratorPhotoStage() { + const count = state.photos.length; + const cover = document.getElementById('generator-cover'); + const empty = document.getElementById('generator-photo-empty'); + const badge = document.getElementById('generator-cover-badge'); + const countEl = document.getElementById('generator-photo-count'); + if (countEl) countEl.textContent = `${count} / 10`; + if (cover && empty && badge) { + if (count) { + cover.src = photoUrl(state.photos[0]); + cover.classList.remove('hidden'); + empty.classList.add('hidden'); + badge.classList.remove('hidden'); + } else { + cover.removeAttribute('src'); + cover.classList.add('hidden'); + empty.classList.remove('hidden'); + badge.classList.add('hidden'); + } + } + const completed = { + count: count >= 3, + cover: count >= 1, + details: count >= 4, + background: count >= 2, + order: count >= 2, + }; + let score = 0; + document.querySelectorAll('#generator-photo-tips [data-photo-tip]').forEach(item => { + const done = Boolean(completed[item.dataset.photoTip]); + item.classList.toggle('is-complete', done); + if (done) score += 1; + }); + const scoreEl = document.getElementById('generator-photo-quality-score'); + if (scoreEl) scoreEl.textContent = `${score} / 5`; + document.getElementById('generator-step-photos')?.classList.toggle('is-complete', count > 0); +} + +function updateGeneratorReadiness() { + const wrapper = document.querySelector('.generator-readiness'); + const title = document.getElementById('generator-readiness-title'); + const copy = document.getElementById('generator-readiness-copy'); + const ready = state.photos.length > 0; + wrapper?.classList.toggle('is-ready', ready); + if (title) title.textContent = ready ? 'Bereit zur Analyse' : 'Fotos fehlen'; + if (copy) copy.textContent = ready + ? `${state.photos.length} Foto${state.photos.length === 1 ? '' : 's'} · ${platformLabel(state.platform)} · ${state.language.toUpperCase()}` + : 'Lade mindestens ein Produktfoto hoch.'; + document.getElementById('generator-step-ai')?.classList.toggle('is-active', ready && !state.currentListing); +} + +function updateGeneratorResultMetrics() { + const title = document.getElementById('result-title')?.value || ''; + const description = editors.result ? editors.result.getText().trim() : ''; + const tagValue = document.getElementById('result-tags')?.value || ''; + const tags = tagValue.split(',').map(tag => tag.trim()).filter(Boolean); + const titleCount = document.getElementById('result-title-count'); + const descriptionCount = document.getElementById('result-description-count'); + const tagsCount = document.getElementById('result-tags-count'); + if (titleCount) titleCount.textContent = `${title.length} / 80`; + if (descriptionCount) descriptionCount.textContent = `${description.length} Zeichen`; + if (tagsCount) tagsCount.textContent = `${tags.length} Tag${tags.length === 1 ? '' : 's'}`; + + let score = 0; + if (title.length >= 30 && title.length <= 80) score += 25; + else if (title.length >= 15) score += 16; + if (description.length >= 180) score += 30; + else if (description.length >= 80) score += 18; + if (tags.length >= 5) score += 15; + else if (tags.length) score += 8; + if (document.getElementById('meta-brand')?.value) score += 8; + if (document.getElementById('meta-category-picker')?.textContent?.trim()) score += 8; + if (parseFloat(document.getElementById('meta-price')?.value)) score += 8; + if (state.photos.length >= 3) score += 6; + score = Math.min(100, score); + const scoreEl = document.getElementById('generator-quality-score'); + const ring = document.getElementById('generator-quality-ring'); + const label = document.getElementById('generator-quality-label'); + if (scoreEl) scoreEl.textContent = `${score}%`; + if (ring) ring.style.setProperty('--quality', `${score}%`); + if (label) { + label.textContent = score >= 85 ? 'Sehr gut' : score >= 65 ? 'Gut' : score >= 40 ? 'Ausbaufähig' : 'Unvollständig'; + label.style.color = score >= 65 ? 'var(--vd-success)' : score >= 40 ? 'var(--vd-warning)' : 'var(--vd-danger)'; + } +} + +async function updateGeneratorFeePreview() { + const price = parseFloat(document.getElementById('meta-price')?.value) || 0; + const feeEl = document.getElementById('fee-info'); + if (!feeEl || !price) { + feeEl?.classList.add('hidden'); + updateGeneratorResultMetrics(); + return; + } + try { + const fees = await api('GET', `/api/fees?platform=${encodeURIComponent(state.platform)}&price=${encodeURIComponent(price)}`); + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${escapeHtml(fees.breakdown || '')}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } catch {} + updateGeneratorResultMetrics(); +} + +async function generateListing() { + const btn = document.getElementById('generate-btn'); + const loading = document.getElementById('loading'); + + btn.disabled = true; + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Analysiert'; previewStatus.className = 'generator-preview-status is-working'; } + document.getElementById('generator-step-ai')?.classList.add('is-active'); + loading.classList.remove('hidden'); + + try { + const body = { + photos: state.photos, + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + variants: state.variants, + template_id: parseInt(document.getElementById('template-select')?.value) || null, + seller_notes: document.getElementById('generator-seller-notes')?.value.trim() || '', + }; + if (state.aiModel) body.ai_model = state.aiModel; + + const data = await api('POST', '/api/generate', body); + + if (data.meta) showMeta(data.meta, data.fees); + + if (data.variants) { + state.allVariants = data.variants; + showVariantTabs(data.variants); + showListing(data.variants[0], data.variants[0].description_html); + } else { + state.allVariants = null; + document.getElementById('variants-section').classList.add('hidden'); + showListing(data.listing, data.description_html); + } + setTimeout(fetchPriceSuggestion, 500); + } catch (err) { + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Fehler'; previewStatus.className = 'generator-preview-status'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + toast('Fehler: ' + err.message); + } finally { + loading.classList.add('hidden'); + btn.disabled = state.photos.length === 0; + updateGeneratorReadiness(); + } +} + +async function showMeta(meta, fees) { + document.getElementById('meta-sku').value = state.currentListing?.sku || ''; + document.getElementById('meta-brand').value = meta.brand || ''; + document.getElementById('meta-size').value = meta.size || ''; + document.getElementById('meta-color').value = meta.color || ''; + document.getElementById('meta-condition').value = meta.condition || ''; + document.getElementById('meta-price').value = meta.suggested_price || ''; + document.getElementById('meta-section').classList.remove('hidden'); + updateGeneratorResultMetrics(); + + let matchedCategory = null; + if (meta.category) { + try { + const matches = await api('GET', `/api/categories/${state.platform}/match?q=${encodeURIComponent(meta.category)}`); + if (matches.length && matches[0].score >= 50) matchedCategory = matches[0].pathStr; + } catch {} + } + metaCategoryPicker = createCategoryPicker('meta-category-picker', state.platform, matchedCategory || meta.category || null); + + const feeEl = document.getElementById('fee-info'); + if (fees) { + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${fees.breakdown}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } else { feeEl.classList.add('hidden'); } +} + +function getEditedMeta() { + return { + brand: document.getElementById('meta-brand').value || null, + category: (metaCategoryPicker ? metaCategoryPicker.getValue() : null) || null, + size: document.getElementById('meta-size').value || null, + color: document.getElementById('meta-color').value || null, + condition: document.getElementById('meta-condition').value || null, + price: parseFloat(document.getElementById('meta-price').value) || null, + storage_location: document.getElementById('meta-storage-location').value || null, + }; +} + +function showVariantTabs(variants) { + const c = document.getElementById('variant-tabs'); + c.innerHTML = ''; + variants.forEach((v, i) => { + const btn = document.createElement('button'); + btn.className = 'variant-tab' + (i === 0 ? ' active' : ''); + btn.textContent = `Variante ${i + 1}`; + btn.addEventListener('click', () => { + c.querySelectorAll('.variant-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showListing(v, v.description_html); + }); + c.appendChild(btn); + }); + document.getElementById('variants-section').classList.remove('hidden'); +} + +function showListing(listing, platformHtml) { + state.currentListing = listing; + const editorHtml = listing.id && listing.description_html + ? sanitizeRichHtmlClient(listing.description_html) + : plainTextToRichHtml(listing.description || ''); + state.currentHtml = platformHtml || ''; + + const skuEl = document.getElementById('meta-sku'); + if (skuEl && listing.sku) skuEl.value = listing.sku; + document.getElementById('result-title').value = listing.title || ''; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, editorHtml); + htmlSyncState.generatorLock = false; + document.getElementById('html-source').value = getEditorHtml(editors.result); + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml || editorHtml); + document.getElementById('html-preview-wrap')?.classList.remove('hidden'); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + document.getElementById('result-tags').value = tags; + const plat = state.platforms.find(p => p.id === state.platform); + document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags'; + document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen'; + document.getElementById('generator-preview-empty')?.classList.add('hidden'); + document.getElementById('result-section').classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Bereit zur Prüfung'; previewStatus.className = 'generator-preview-status is-ready'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + document.getElementById('generator-step-ai')?.classList.add('is-complete'); + document.getElementById('generator-step-result')?.classList.add('is-active'); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); +} + +// --- Copy --- +function setupCopy() { + document.querySelectorAll('.copy-btn[data-target]').forEach(btn => { + btn.addEventListener('click', () => { + navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value); + toast('Kopiert!'); + }); + }); + document.getElementById('copy-desc-text').addEventListener('click', () => { + if (editors.result) { + navigator.clipboard.writeText(editors.result.getText().trim()); + toast('Text kopiert!'); + } + }); + document.getElementById('copy-desc-html').addEventListener('click', () => { + const src = document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Editor-HTML kopiert!'); + } + }); + document.getElementById('copy-platform-html')?.addEventListener('click', () => { + const src = state.currentHtml || document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Plattform-HTML kopiert!'); + } + }); + document.getElementById('format-html-source')?.addEventListener('click', () => { + const source = document.getElementById('html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, safe); + htmlSyncState.generatorLock = false; + refreshGeneratorPlatformPreview(); + toast('HTML bereinigt und synchronisiert'); + }); + document.getElementById('copy-all-btn').addEventListener('click', () => { + const t = document.getElementById('result-title').value; + const d = editors.result ? editors.result.getText().trim() : ''; + const tg = document.getElementById('result-tags').value; + navigator.clipboard.writeText(`${t}\n\n${d}\n\n${tg}`); + toast('Alles kopiert!'); + }); + document.getElementById('save-btn').addEventListener('click', async () => { + if (!state.currentListing) return; + const tags = document.getElementById('result-tags').value.split(',').map(t => t.trim()).filter(Boolean); + const meta = getEditedMeta(); + const descriptionHtml = getEditorHtml(editors.result); + const description = richHtmlToText(descriptionHtml); + const payload = { + title: document.getElementById('result-title').value, + description, + description_html: descriptionHtml, + tags, + photos: [...state.photos], + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + ...meta, + }; + try { + if (state.currentListing.id) { + await api('PUT', `/api/listings/${state.currentListing.id}`, { ...payload, _edit_lock: listingEditLock.listingId === Number(state.currentListing.id) ? listingEditLock.token : null }); + toast('Gespeichert!'); + } else { + await api('POST', '/api/listings', { + ...state.currentListing, + ...payload, + }); + toast('Listing hinzugefügt!'); + notify('Vendoo', `"${payload.title}" gespeichert`); + resetGenerator(); + } + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function resetGenerator() { + state.currentListing = null; + state.currentHtml = ''; + state.allVariants = null; + state.photos = []; + state.aiModelPhotos = []; + metaCategoryPicker = null; + document.getElementById('photo-preview').innerHTML = ''; + document.getElementById('image-tools').classList.add('hidden'); + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen'; + document.getElementById('generate-btn').disabled = true; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + document.getElementById('template-select').value = ''; + const notes = document.getElementById('generator-seller-notes'); + if (notes) notes.value = ''; + const notesCount = document.getElementById('generator-notes-count'); + if (notesCount) notesCount.textContent = '0 / 500'; + const templateTags = document.getElementById('generator-template-tags'); + if (templateTags) { templateTags.innerHTML = ''; templateTags.classList.add('hidden'); } + if (editors.result) editors.result.setText(''); + const htmlSource = document.getElementById('html-source'); + if (htmlSource) htmlSource.value = ''; + setSandboxPreview(document.getElementById('html-preview'), ''); + document.getElementById('result-title').value = ''; + document.getElementById('result-tags').value = ''; + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Wartet'; previewStatus.className = 'generator-preview-status'; } + ['generator-step-ai', 'generator-step-result'].forEach(id => { + document.getElementById(id)?.classList.remove('is-active', 'is-complete'); + }); + document.getElementById('generator-step-photos')?.classList.remove('is-complete'); + document.getElementById('generator-step-photos')?.classList.add('is-active'); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +// --- Listings / Historie --- +const historyState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + hiddenColumns: new Set(), +}; + +const HISTORY_COLUMN_STORAGE = 'vendoo-history-columns'; + +function setupHistory() { + const search = document.getElementById('search-input'); + const platform = document.getElementById('filter-platform'); + const status = document.getElementById('filter-status'); + const period = document.getElementById('history-period'); + const dateFrom = document.getElementById('filter-date-from'); + const dateTo = document.getElementById('filter-date-to'); + + search.addEventListener('input', debounce(() => { state.historyPage = 1; loadHistory(); }, 300)); + platform.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + status.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + period.addEventListener('change', () => { + applyHistoryPeriod(period.value); + state.historyPage = 1; + loadHistory(); + }); + dateFrom.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + dateTo.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + + document.getElementById('history-reset-filters').addEventListener('click', () => { + search.value = ''; + platform.value = ''; + status.value = ''; + period.value = '90'; + applyHistoryPeriod('90'); + state.historyPage = 1; + loadHistory(); + }); + + document.getElementById('back-to-list').addEventListener('click', async () => { await releaseListingLock({ quiet: true }); setHistoryDetailMode(false); }); + + document.getElementById('history-select-all').addEventListener('change', e => { + const checked = e.target.checked; + historyState.listings.forEach(listing => { + if (checked) historyState.selected.add(listing.id); + else historyState.selected.delete(listing.id); + }); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = checked; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.toggle('selected', checked)); + updateHistoryToolbar(); + }); + + document.getElementById('history-clear-selection').addEventListener('click', () => { + historyState.selected.clear(); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = false; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.remove('selected')); + document.getElementById('history-select-all').checked = false; + updateHistoryToolbar(); + }); + + document.getElementById('history-delete-selected').addEventListener('click', deleteSelectedHistoryListings); + document.getElementById('history-delete-all').addEventListener('click', async () => { + if (!historyState.allFiltered.length) return; + if (!confirm(`Alle ${historyState.allFiltered.length} gefilterten Artikel in den Papierkorb verschieben?`)) return; + for (const listing of historyState.allFiltered) await api('DELETE', `/api/listings/${listing.id}`); + historyState.selected.clear(); + toast('Alle gefilterten Artikel wurden in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + }); + + document.getElementById('history-bulk-status').addEventListener('click', bulkUpdateHistoryStatus); + document.getElementById('history-price-selected').addEventListener('click', bulkUpdateHistoryPrice); + document.getElementById('history-duplicate-selected').addEventListener('click', bulkDuplicateHistoryListings); + document.getElementById('history-edit-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (ids.length !== 1) { toast('Zum Nachbearbeiten bitte genau ein Artikel auswählen'); return; } + showDetail(ids[0]); + }); + document.getElementById('history-publish-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (!ids.length) return; + publishState.selected = new Set(ids); + switchToTab('publish'); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} für Publish ausgewählt`); + }); + document.getElementById('history-print-labels').addEventListener('click', () => { + const selected = historyState.allFiltered.filter(listing => historyState.selected.has(listing.id)); + if (selected.length) printLabels(selected); + }); + + document.getElementById('export-csv-btn').addEventListener('click', () => { + const params = new URLSearchParams(); + if (platform.value) params.set('platform', platform.value); + if (status.value) params.set('status', status.value); + window.open(`/api/export/csv?${params}`, '_blank'); + closeHistoryPopovers(); + }); + document.getElementById('export-json-btn').addEventListener('click', () => { + const data = historyState.allFiltered; + if (!data.length) { toast('Keine Artikel zum Exportieren'); return; } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const anchor = document.createElement('a'); + anchor.href = URL.createObjectURL(blob); + anchor.download = `vendoo-export-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(anchor.href); + toast(`${data.length} Artikel als JSON exportiert`); + closeHistoryPopovers(); + }); + + setupHistoryPopovers(); + setupHistoryColumns(); + + const pageSizeSelect = document.getElementById('history-page-size-select'); + pageSizeSelect.value = String(state.historyPageSize); + pageSizeSelect.addEventListener('change', () => { + state.historyPageSize = Number(pageSizeSelect.value); + state.historyPage = 1; + loadHistory(); + }); + + setupPagination('history'); + applyHistoryPeriod(period.value || '90'); +} + +function applyHistoryPeriod(value) { + const custom = document.getElementById('history-custom-dates'); + const fromInput = document.getElementById('filter-date-from'); + const toInput = document.getElementById('filter-date-to'); + custom.classList.toggle('hidden', value !== 'custom'); + if (value === 'custom') return; + + const today = new Date(); + const iso = date => date.toISOString().slice(0, 10); + toInput.value = value === 'all' ? '' : iso(today); + + if (value === 'all') { + fromInput.value = ''; + } else if (value === 'year') { + fromInput.value = `${today.getFullYear()}-01-01`; + } else { + const days = Number(value) || 90; + const from = new Date(today); + from.setDate(from.getDate() - days + 1); + fromInput.value = iso(from); + } +} + +function setupHistoryPopovers() { + const exportButton = document.getElementById('history-export-toggle'); + const exportMenu = document.getElementById('history-export-menu'); + const columnsButton = document.getElementById('history-columns-btn'); + const columnsPanel = document.getElementById('history-columns-panel'); + + exportButton.addEventListener('click', event => { + event.stopPropagation(); + const open = exportMenu.classList.toggle('hidden') === false; + columnsPanel.classList.add('hidden'); + exportButton.setAttribute('aria-expanded', String(open)); + columnsButton.setAttribute('aria-expanded', 'false'); + }); + columnsButton.addEventListener('click', event => { + event.stopPropagation(); + const open = columnsPanel.classList.toggle('hidden') === false; + exportMenu.classList.add('hidden'); + columnsButton.setAttribute('aria-expanded', String(open)); + exportButton.setAttribute('aria-expanded', 'false'); + }); + exportMenu.addEventListener('click', event => event.stopPropagation()); + columnsPanel.addEventListener('click', event => event.stopPropagation()); + document.addEventListener('click', closeHistoryPopovers); +} + +function closeHistoryPopovers() { + document.getElementById('history-export-menu')?.classList.add('hidden'); + document.getElementById('history-columns-panel')?.classList.add('hidden'); + document.getElementById('history-export-toggle')?.setAttribute('aria-expanded', 'false'); + document.getElementById('history-columns-btn')?.setAttribute('aria-expanded', 'false'); + document.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); +} + +function setupHistoryColumns() { + try { + const saved = JSON.parse(localStorage.getItem(HISTORY_COLUMN_STORAGE) || '[]'); + historyState.hiddenColumns = new Set(Array.isArray(saved) ? saved : []); + } catch { historyState.hiddenColumns = new Set(); } + + document.querySelectorAll('[data-history-column]').forEach(input => { + input.checked = !historyState.hiddenColumns.has(input.dataset.historyColumn); + input.addEventListener('change', () => { + const column = input.dataset.historyColumn; + if (input.checked) historyState.hiddenColumns.delete(column); + else historyState.hiddenColumns.add(column); + localStorage.setItem(HISTORY_COLUMN_STORAGE, JSON.stringify([...historyState.hiddenColumns])); + applyHistoryColumns(); + }); + }); + applyHistoryColumns(); +} + +function applyHistoryColumns() { + const table = document.getElementById('history-table'); + if (!table) return; + ['platform', 'provider', 'price', 'status', 'location', 'published'].forEach(column => { + table.classList.toggle(`history-hide-${column}`, historyState.hiddenColumns.has(column)); + }); +} + +function setHistoryDetailMode(enabled) { + document.querySelector('.listings-heading')?.classList.toggle('hidden', enabled); + document.querySelector('.history-filterbar')?.classList.toggle('hidden', enabled); + document.querySelector('.history-table-shell')?.classList.toggle('hidden', enabled); + document.getElementById('history-detail')?.classList.toggle('hidden', !enabled); +} + +async function deleteSelectedHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + if (!confirm(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschieben?`)) return; + for (const id of ids) await api('DELETE', `/api/listings/${id}`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschoben`); + await loadHistory(); + updateTrashBadge(); +} + +async function bulkUpdateHistoryStatus() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Status: aktiv, verkauft oder reserviert'); + if (!answer) return; + const normalized = answer.trim().toLowerCase(); + const map = { aktiv: 'active', active: 'active', verkauft: 'sold', sold: 'sold', reserviert: 'reserved', reserved: 'reserved' }; + const newStatus = map[normalized]; + if (!newStatus) { toast('Unbekannter Status'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { status: newStatus }))); + toast(`Status für ${ids.length} Artikel aktualisiert`); + loadHistory(); +} + +async function bulkUpdateHistoryPrice() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Preis in Euro für alle ausgewählten Artikel:'); + if (answer === null) return; + const price = Number(String(answer).replace(',', '.')); + if (!Number.isFinite(price) || price < 0) { toast('Bitte einen gültigen Preis eingeben'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { price }))); + toast(`Preis für ${ids.length} Artikel aktualisiert`); + loadHistory(); +} + +async function bulkDuplicateHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + for (const id of ids) await api('POST', `/api/listings/${id}/duplicate`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} dupliziert`); + loadHistory(); +} + +function setupPagination(prefix) { + const topBar = document.getElementById(`${prefix}-pagination-top`); + if (!topBar) return; + topBar.querySelectorAll('.page-size-btn').forEach(btn => { + btn.addEventListener('click', () => { + topBar.querySelectorAll('.page-size-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const size = parseInt(btn.dataset.size); + if (prefix === 'history') { state.historyPageSize = size; state.historyPage = 1; loadHistory(); } + else { state.publishPageSize = size; state.publishPage = 1; loadPublishList(); } + }); + }); + document.getElementById(`${prefix}-prev`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); + document.getElementById(`${prefix}-prev-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); +} + +function updatePaginationUI(prefix, page, totalPages, totalItems) { + const info = `Seite ${page} von ${totalPages} (${totalItems} Einträge)`; + document.getElementById(`${prefix}-page-info`)?.replaceChildren(document.createTextNode(info)); + document.getElementById(`${prefix}-page-info-bottom`)?.replaceChildren(document.createTextNode(info)); + + const prevDisabled = page <= 1; + const nextDisabled = page >= totalPages; + const prev = document.getElementById(`${prefix}-prev`); + const next = document.getElementById(`${prefix}-next`); + const prevBottom = document.getElementById(`${prefix}-prev-bottom`); + const nextBottom = document.getElementById(`${prefix}-next-bottom`); + if (prev) prev.disabled = prevDisabled; + if (next) next.disabled = nextDisabled; + if (prevBottom) prevBottom.disabled = prevDisabled; + if (nextBottom) nextBottom.disabled = nextDisabled; + + if (prefix === 'history') { + const totalInfo = document.getElementById('history-total-info'); + if (totalInfo) totalInfo.textContent = `von ${totalItems} Einträgen`; + renderHistoryPageButtons(page, totalPages); + } +} + +function renderHistoryPageButtons(page, totalPages) { + const container = document.getElementById('history-page-buttons'); + if (!container) return; + const pages = new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2]); + const valid = [...pages].filter(value => value >= 1 && value <= totalPages).sort((a, b) => a - b); + const parts = []; + let previous = 0; + for (const value of valid) { + if (previous && value - previous > 1) parts.push(''); + parts.push(``); + previous = value; + } + container.innerHTML = parts.join(''); + container.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => { + state.historyPage = Number(button.dataset.page); + loadHistory(); + })); +} + +function paginateList(allItems, page, pageSize) { + if (!pageSize || pageSize <= 0) return { items: allItems, page: 1, totalPages: 1 }; + const totalPages = Math.max(1, Math.ceil(allItems.length / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * pageSize; + return { items: allItems.slice(start, start + pageSize), page: safePage, totalPages }; +} + +async function loadHistory() { + const query = document.getElementById('search-input').value.trim(); + const platform = document.getElementById('filter-platform').value; + const status = document.getElementById('filter-status').value; + const dateFrom = document.getElementById('filter-date-from').value; + const dateTo = document.getElementById('filter-date-to').value; + + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + let listings = await api('GET', `/api/listings?${params.toString()}`); + + if (dateFrom) { + const from = new Date(`${dateFrom}T00:00:00`); + listings = listings.filter(listing => historyParseDate(listing.created_at) >= from); + } + if (dateTo) { + const to = new Date(`${dateTo}T23:59:59.999`); + listings = listings.filter(listing => historyParseDate(listing.created_at) <= to); + } + + historyState.allFiltered = listings; + const { items, page, totalPages } = paginateList(listings, state.historyPage, state.historyPageSize); + state.historyPage = page; + historyState.listings = items; + updatePaginationUI('history', page, totalPages, listings.length); + + if (items.length) { + const ids = items.map(listing => listing.id).join(','); + try { historyState.publishStatus = await api('GET', `/api/publish/status?ids=${ids}`); } + catch { historyState.publishStatus = {}; } + } else { + historyState.publishStatus = {}; + } + + renderHistoryList(items); +} + +function historyParseDate(value) { + if (!value) return new Date(0); + const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value.replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? new Date(0) : date; +} + +function historyFormatDate(value, withTime = false) { + const date = historyParseDate(value); + if (!date.getTime()) return '—'; + return date.toLocaleString('de-DE', withTime + ? { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' } + : { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function historyProviderLabel(provider) { + return ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || '—'; +} + +function historyStatusMeta(status) { + return ({ + active: { label: 'Aktiv', className: 'active' }, + sold: { label: 'Verkauft', className: 'sold' }, + reserved: { label: 'Reserviert', className: 'reserved' }, + })[status] || { label: status || 'Aktiv', className: 'active' }; +} + +function historyPublishSummary(listing) { + const statuses = historyState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return 'Nicht veröffentlicht'; + return entries.slice(0, 2).map(([platform, status]) => { + const meta = { + published: ['Live', 'published'], + copied: ['Kopiert', 'copied'], + pending: ['Ausstehend', 'pending'], + failed: ['Fehler', 'failed'], + }[status] || [status, 'pending']; + return `${escapeHtml(platformLabel(platform))}${escapeHtml(meta[0])}`; + }).join(''); +} + +function renderHistoryList(listings) { + const container = document.getElementById('history-list'); + if (!listings.length) { + container.innerHTML = `
                          +
                          +

                          Keine Artikel gefunden

                          +

                          Ändere die Filter oder erstelle ein neues Listing.

                          + +
                          `; + container.querySelector('[data-empty-new-listing]')?.addEventListener('click', () => switchToTab('generator')); + updateHistoryToolbar(); + applyHistoryColumns(); + return; + } + + container.innerHTML = listings.map(listing => { + const checked = historyState.selected.has(listing.id); + const photo = listing.photos?.[0] + ? `` + : ''; + const status = historyStatusMeta(listing.status || 'active'); + const provider = historyProviderLabel(listing.ai_provider); + const platform = platformLabel(listing.platform); + const price = listing.price !== null && listing.price !== undefined ? formatCurrency(listing.price) : '—'; + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + + return `
                          +
                          +
                          + ${photo} +
                          + +
                          SKU  ${escapeHtml(sku)}
                          +
                          +
                          +
                          ${escapeHtml(platform)}${escapeHtml((listing.language || 'de').toUpperCase())}
                          +
                          ${escapeHtml(provider.slice(0, 1))}${escapeHtml(provider)}Vision AI
                          +
                          ${escapeHtml(price)}Festpreis
                          +
                          + + Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))} +
                          +
                          ${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}${escapeHtml(listing.category || '—')}
                          +
                          ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}
                          ${historyPublishSummary(listing)}
                          +
                          + + +
                          +
                          `; + }).join(''); + + container.querySelectorAll('.history-row-select').forEach(checkbox => { + checkbox.addEventListener('click', event => event.stopPropagation()); + checkbox.addEventListener('change', () => { + const id = Number(checkbox.dataset.id); + if (checkbox.checked) historyState.selected.add(id); + else historyState.selected.delete(id); + checkbox.closest('.history-row')?.classList.toggle('selected', checkbox.checked); + updateHistoryToolbar(); + }); + }); + + container.querySelectorAll('[data-open-listing]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + showDetail(Number(button.dataset.openListing)); + })); + + container.querySelectorAll('.history-row').forEach(row => row.addEventListener('dblclick', event => { + if (!event.target.closest('button, input, select')) showDetail(Number(row.dataset.id)); + })); + + container.querySelectorAll('.history-status-select').forEach(select => { + select.addEventListener('click', event => event.stopPropagation()); + select.addEventListener('change', async () => { + await api('PUT', `/api/listings/${select.dataset.id}`, { status: select.value }); + toast('Status aktualisiert'); + loadHistory(); + }); + }); + + container.querySelectorAll('[data-row-menu]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + const panel = container.querySelector(`[data-row-menu-panel="${button.dataset.rowMenu}"]`); + const willOpen = panel?.classList.contains('hidden'); + container.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); + if (willOpen) panel?.classList.remove('hidden'); + })); + + container.querySelectorAll('[data-row-action]').forEach(button => button.addEventListener('click', async event => { + event.stopPropagation(); + const id = Number(button.dataset.id); + const action = button.dataset.rowAction; + if (action === 'open') showDetail(id); + if (action === 'duplicate') { + const copy = await api('POST', `/api/listings/${id}/duplicate`); + toast(`Kopie erstellt: „${copy.title}“`); + loadHistory(); + } + if (action === 'delete') { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${id}`); + historyState.selected.delete(id); + toast('Listing in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + } + })); + + document.getElementById('history-select-all').checked = listings.every(listing => historyState.selected.has(listing.id)); + updateHistoryToolbar(); + applyHistoryColumns(); +} + +function updateHistoryToolbar() { + const count = historyState.selected.size; + const countLabel = document.getElementById('history-selected-count'); + if (countLabel) countLabel.textContent = `${count} ausgewählt`; + ['history-delete-selected', 'history-bulk-status', 'history-print-labels', 'history-publish-selected', 'history-edit-selected', 'history-price-selected', 'history-duplicate-selected'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + const clear = document.getElementById('history-clear-selection'); + clear?.classList.toggle('hidden', count === 0); + document.getElementById('history-batch-bar')?.classList.toggle('has-selection', count > 0); + const selectAll = document.getElementById('history-select-all'); + if (selectAll) { + const selectedOnPage = historyState.listings.filter(listing => historyState.selected.has(listing.id)).length; + selectAll.checked = historyState.listings.length > 0 && selectedOnPage === historyState.listings.length; + selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < historyState.listings.length; + } +} + +let detailRenderSequence = 0; +let detailPhotoDragIndex = -1; + +function detailPlatformOptions(selected) { + return state.platforms.map(platform => ``).join(''); +} + +function detailProviderOptions(selected) { + return state.providers.map(provider => ``).join(''); +} + +function detailLanguageOptions(selected) { + const languages = [['de', 'Deutsch'], ['en', 'English'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['es', 'Español'], ['it', 'Italiano'], ['pt', 'Português']]; + return languages.map(([value, label]) => ``).join(''); +} + +function getDetailListingForHtml() { + if (!detailEditorState) return null; + const tags = (document.getElementById('detail-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || '', + description: editors.detail ? editors.detail.getText().trim() : '', + description_html: getEditorHtml(editors.detail), + tags, + brand: document.getElementById('detail-brand')?.value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size')?.value || null, + color: document.getElementById('detail-color')?.value || null, + condition: document.getElementById('detail-condition')?.value || null, + }; +} + +const refreshDetailPlatformPreview = debounce(async () => { + const listing = getDetailListingForHtml(); + if (!listing) return; + const sequence = ++detailRenderSequence; + setHtmlSyncStatus('detail-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const data = await api('POST', '/api/html-render', { platform: listing.platform, listing }); + if (sequence !== detailRenderSequence) return; + detailEditorState.platformHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('detail-html-preview'), detailEditorState.platformHtml); + setHtmlSyncStatus('detail-html-sync-status', 'Synchron', 'ok'); + } catch { + if (sequence !== detailRenderSequence) return; + setSandboxPreview(document.getElementById('detail-html-preview'), listing.description_html); + setHtmlSyncStatus('detail-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupDetailHtmlSync() { + const editor = editors.detail; + const source = document.getElementById('detail-html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.detailLock) return; + source.value = getEditorHtml(editor); + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }); + + const applySource = debounce(() => { + htmlSyncState.detailLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.detailLock = false; + source.value = safe; + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('detail-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('detail-html-preview'), sanitizeRichHtmlClient(source.value)); + applySource(); + }); +} + +function renderDetailPhotos() { + if (!detailEditorState) return; + const photos = detailEditorState.photos; + const hero = document.getElementById('detail-photo-hero'); + const empty = document.getElementById('detail-photo-empty'); + const strip = document.getElementById('detail-photo-strip'); + const count = document.getElementById('detail-photo-count'); + const activeIndex = Math.min(detailEditorState.activePhotoIndex || 0, Math.max(0, photos.length - 1)); + detailEditorState.activePhotoIndex = activeIndex; + if (count) count.textContent = `${photos.length} / 10`; + if (hero && empty) { + if (photos.length) { + hero.src = photoUrl(photos[activeIndex]); + hero.alt = `Produktfoto ${activeIndex + 1}`; + hero.classList.remove('hidden'); + empty.classList.add('hidden'); + } else { + hero.removeAttribute('src'); + hero.classList.add('hidden'); + empty.classList.remove('hidden'); + } + } + ['detail-edit-photo', 'detail-set-cover', 'detail-remove-photo'].forEach(id => { + const button = document.getElementById(id); + if (button) button.disabled = photos.length === 0; + }); + if (!strip) return; + strip.innerHTML = photos.map((photo, index) => ` + `).join(''); + strip.querySelectorAll('[data-detail-photo-index]').forEach(button => { + const index = Number(button.dataset.detailPhotoIndex); + button.addEventListener('click', () => { detailEditorState.activePhotoIndex = index; renderDetailPhotos(); }); + button.addEventListener('dragstart', event => { detailPhotoDragIndex = index; event.dataTransfer.effectAllowed = 'move'; button.classList.add('is-dragging'); }); + button.addEventListener('dragend', () => { detailPhotoDragIndex = -1; button.classList.remove('is-dragging'); }); + button.addEventListener('dragover', event => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }); + button.addEventListener('drop', event => { + event.preventDefault(); + if (detailPhotoDragIndex < 0 || detailPhotoDragIndex === index) return; + const [moved] = detailEditorState.photos.splice(detailPhotoDragIndex, 1); + detailEditorState.photos.splice(index, 0, moved); + detailEditorState.activePhotoIndex = index; + detailPhotoDragIndex = -1; + renderDetailPhotos(); + }); + }); +} + +async function uploadDetailPhotos(files) { + if (!detailEditorState) return; + const available = Math.max(0, 10 - detailEditorState.photos.length); + const accepted = Array.from(files || []).filter(file => file.type.startsWith('image/')).slice(0, available); + if (!available) return toast('Maximal 10 Fotos pro Listing'); + if (!accepted.length) return; + const platform = document.getElementById('detail-platform')?.value || detailEditorState.listing.platform || 'allgemein'; + const formData = new FormData(); + formData.append('platform', platform); + accepted.forEach(file => formData.append('photos', file)); + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, { + method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen'); + detailEditorState.photos.push(...(data.filenames || [])); + detailEditorState.activePhotoIndex = Math.max(0, detailEditorState.photos.length - (data.filenames || []).length); + renderDetailPhotos(); + toast(`${(data.filenames || []).length} Foto${(data.filenames || []).length === 1 ? '' : 's'} hinzugefügt`); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function setupDetailPhotoActions() { + const fileInput = document.getElementById('detail-photo-input'); + document.getElementById('detail-add-photo')?.addEventListener('click', () => fileInput?.click()); + document.getElementById('detail-mobile-photo')?.addEventListener('click', () => openMobileUpload('listing', detailEditorState?.listing?.id)); + fileInput?.addEventListener('change', () => { uploadDetailPhotos(fileInput.files); fileInput.value = ''; }); + document.getElementById('detail-edit-photo')?.addEventListener('click', () => openPhotoEditor(detailEditorState.activePhotoIndex, 'detail')); + document.getElementById('detail-set-cover')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (index <= 0 || !detailEditorState.photos[index]) return; + const [photo] = detailEditorState.photos.splice(index, 1); + detailEditorState.photos.unshift(photo); + detailEditorState.activePhotoIndex = 0; + renderDetailPhotos(); + toast('Coverfoto festgelegt'); + }); + document.getElementById('detail-download-photos')?.addEventListener('click', () => downloadPublishPhotos(detailEditorState?.photos || [])); + document.getElementById('detail-remove-photo')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (!detailEditorState.photos[index]) return; + detailEditorState.photos.splice(index, 1); + detailEditorState.activePhotoIndex = Math.max(0, Math.min(index, detailEditorState.photos.length - 1)); + renderDetailPhotos(); + }); +} + +async function showDetail(id) { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + toast('Ungültige Listing-ID'); + return; + } + + const historySection = document.getElementById('history'); + if (!historySection?.classList.contains('active')) { + switchToTab('history'); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + const l = await api('GET', `/api/listings/${numericId}`); + const tags = Array.isArray(l.tags) ? l.tags.join(', ') : (l.tags || ''); + const skuVal = l.sku || `VD-${String(l.id).padStart(4, '0')}`; + detailEditorState = { + listing: l, + photos: [...(Array.isArray(l.photos) ? l.photos : [])], + activePhotoIndex: 0, + categoryPicker: null, + platformHtml: '', + aiModelPhotos: [], + aiModelGenerations: [], + }; + + document.getElementById('detail-content').innerHTML = ` +
                          +
                          +
                          + Artikel vollständig bearbeiten +

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

                          +

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

                          +
                          +
                          + + +
                          +
                          + +
                          + + +
                          +
                          +
                          02

                          Listing-Daten

                          +
                          + + + + + + +
                          + + +
                          + +
                          +
                          03

                          Beschreibung

                          0 Zeichen
                          +
                          WYSIWYGFormatierte Bearbeitung
                          +
                          +
                          +
                          + + +
                          + Synchron +
                          +
                          +
                          +
                          +
                          +
                          +
                          + +
                          +
                          04

                          Eigenschaften & Preis

                          +
                          + + + + + + +
                          + +
                          +
                          + + +
                          +
                          `; + + const lockResult = await acquireListingLock(numericId); + if (!userCan('listings.edit') || lockResult.readOnly) { + document.querySelectorAll('#history-detail input, #history-detail textarea, #history-detail select').forEach(element => { element.disabled = true; }); + document.getElementById('detail-save')?.setAttribute('disabled', 'disabled'); + if (!lockResult.lock && !userCan('listings.edit')) showListingLockBanner({ user_name: 'Deine Rolle erlaubt nur Lesezugriff' }, false); + } + + editors.detail = initQuill('#detail-description-editor', 'Beschreibung...'); + const detailReadOnly = !userCan('listings.edit') || lockResult.readOnly; + if (detailReadOnly) editors.detail?.enable?.(false); + const initialHtml = l.description_html ? sanitizeRichHtmlClient(l.description_html) : plainTextToRichHtml(l.description || ''); + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, initialHtml); + htmlSyncState.detailLock = false; + document.getElementById('detail-html-source').value = getEditorHtml(editors.detail); + document.getElementById('detail-description-count').textContent = `${editors.detail ? editors.detail.getText().trim().length : 0} Zeichen`; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', l.platform, l.category || null); + setupDetailHtmlSync(); + bindHtmlViewTabs('detail'); + setupDetailPhotoActions(); + if (detailReadOnly) { + const mutationButtons = [ + 'detail-save', 'detail-delete', 'detail-add-photo', 'detail-mobile-photo', + 'detail-edit-photo', 'detail-set-cover', 'detail-remove-photo', + 'detail-ai-model-prompt-btn', 'detail-ai-model-btn', 'detail-clean-html', + ]; + mutationButtons.forEach(id => { + const element = document.getElementById(id); + if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); } + }); + } + document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos); + document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail')); + document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail')); + updateAiModeControls('detail'); + renderDetailPhotos(); + renderAiModelGrid('detail', []); + addQuillTooltips(); + refreshDetailPlatformPreview(); + loadDetailAiModelPhotos(numericId); + loadDetailAiModelGenerations(numericId); + + if (typeof QRious !== 'undefined') { + new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' }); + } + + const refreshFields = ['detail-title', 'detail-tags', 'detail-brand', 'detail-size', 'detail-color', 'detail-condition']; + refreshFields.forEach(fieldId => document.getElementById(fieldId)?.addEventListener('input', refreshDetailPlatformPreview)); + document.getElementById('detail-title')?.addEventListener('input', event => { + document.getElementById('detail-title-count').textContent = `${event.target.value.length} / 120`; + }); + document.getElementById('detail-platform')?.addEventListener('change', event => { + const currentCategory = detailEditorState.categoryPicker?.getValue?.() || null; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', event.target.value, currentCategory); + refreshDetailPlatformPreview(); + }); + document.getElementById('detail-sku')?.addEventListener('input', event => { document.getElementById('detail-sku-display').textContent = event.target.value || 'Ohne SKU'; }); + + document.getElementById('detail-copy-editor-html').addEventListener('click', () => { navigator.clipboard.writeText(getEditorHtml(editors.detail)); toast('Editor-HTML kopiert'); }); + document.getElementById('detail-copy-platform-html').addEventListener('click', () => { navigator.clipboard.writeText(detailEditorState.platformHtml || getEditorHtml(editors.detail)); toast('Plattform-HTML kopiert'); }); + document.getElementById('detail-clean-html').addEventListener('click', () => { + const source = document.getElementById('detail-html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, safe); + htmlSyncState.detailLock = false; + refreshDetailPlatformPreview(); + toast('HTML bereinigt'); + }); + + document.getElementById('detail-print-label').addEventListener('click', () => printLabels([{ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, price: parseFloat(document.getElementById('detail-price').value) || null }])); + document.getElementById('detail-print-sheet').addEventListener('click', () => printDatasheet({ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, description: editors.detail?.getText().trim() || '', price: parseFloat(document.getElementById('detail-price').value) || null })); + + document.getElementById('detail-save').addEventListener('click', async () => { + const descriptionHtml = getEditorHtml(editors.detail); + const update = { + platform: document.getElementById('detail-platform').value, + ai_provider: document.getElementById('detail-ai-provider').value, + language: document.getElementById('detail-language').value, + title: document.getElementById('detail-title').value, + description: richHtmlToText(descriptionHtml), + description_html: descriptionHtml, + tags: document.getElementById('detail-tags').value.split(',').map(tag => tag.trim()).filter(Boolean), + photos: [...detailEditorState.photos], + seller_notes: document.getElementById('detail-seller-notes').value || null, + status: document.getElementById('detail-status').value, + sku: document.getElementById('detail-sku').value || null, + brand: document.getElementById('detail-brand').value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size').value || null, + color: document.getElementById('detail-color').value || null, + condition: document.getElementById('detail-condition').value || null, + price: parseFloat(document.getElementById('detail-price').value) || null, + storage_location: document.getElementById('detail-storage-location').value || null, + }; + try { + const saved = await api('PUT', `/api/listings/${numericId}`, { ...update, _edit_lock: listingEditLock.listingId === numericId ? listingEditLock.token : null }); + detailEditorState.listing = saved; + toast('Alle Änderungen gespeichert'); + loadHistory(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('detail-copy-all').addEventListener('click', () => { + const content = `${document.getElementById('detail-title').value}\n\n${editors.detail?.getText().trim() || ''}\n\n${document.getElementById('detail-tags').value}`; + navigator.clipboard.writeText(content); + toast('Alles kopiert!'); + }); + + document.getElementById('detail-delete').addEventListener('click', async () => { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${numericId}`); + toast('In Papierkorb verschoben'); + updateTrashBadge(); + document.getElementById('back-to-list').click(); + loadHistory(); + }); + + setHistoryDetailMode(true); + document.getElementById('history-detail')?.scrollIntoView({ block: 'start' }); +} + +// --- Templates --- +function setupTemplates() { + document.getElementById('add-template-btn').addEventListener('click', () => { + state.editingTemplateId = null; + document.getElementById('template-form-title').textContent = 'Neue Vorlage'; + document.getElementById('tpl-name').value = ''; + document.getElementById('tpl-platform').value = ''; + document.getElementById('tpl-language').value = 'de'; + if (editors.tplNotes) editors.tplNotes.setText(''); + document.getElementById('tpl-tags').value = ''; + document.getElementById('template-form').classList.remove('hidden'); + }); + document.getElementById('cancel-template-btn').addEventListener('click', () => { + document.getElementById('template-form').classList.add('hidden'); + }); + document.getElementById('save-template-btn').addEventListener('click', async () => { + const data = { + name: document.getElementById('tpl-name').value, + platform: document.getElementById('tpl-platform').value || null, + language: document.getElementById('tpl-language').value, + seller_notes: editors.tplNotes ? editors.tplNotes.getText().trim() : '', + default_tags: document.getElementById('tpl-tags').value.split(',').map(t => t.trim()).filter(Boolean), + }; + if (!data.name) { toast('Name ist Pflichtfeld'); return; } + try { + if (state.editingTemplateId) { + await api('PUT', `/api/templates/${state.editingTemplateId}`, data); + } else { + await api('POST', '/api/templates', data); + } + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('template-form').classList.add('hidden'); + toast('Vorlage gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function renderTemplateList() { + const c = document.getElementById('template-list'); + if (!state.templates.length) { c.innerHTML = '
                          Keine Vorlagen vorhanden
                          '; return; } + c.innerHTML = state.templates.map(t => { + const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen'; + return `
                          +
                          +
                          ${escapeHtml(t.name)}
                          +
                          ${pName} · ${t.language.toUpperCase()}
                          +
                          +
                          + + +
                          +
                          `; + }).join(''); + + c.querySelectorAll('.tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const t = state.templates.find(x => x.id === parseInt(btn.dataset.id)); + if (!t) return; + state.editingTemplateId = t.id; + document.getElementById('template-form-title').textContent = 'Vorlage bearbeiten'; + document.getElementById('tpl-name').value = t.name; + document.getElementById('tpl-platform').value = t.platform || ''; + document.getElementById('tpl-language').value = t.language || 'de'; + if (editors.tplNotes) editors.tplNotes.setText(t.seller_notes || ''); + document.getElementById('tpl-tags').value = (t.default_tags || []).join(', '); + document.getElementById('template-form').classList.remove('hidden'); + }); + }); + c.querySelectorAll('.tpl-delete').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Vorlage löschen?')) return; + await api('DELETE', `/api/templates/${btn.dataset.id}`); + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + toast('Vorlage gelöscht'); + }); + }); +} + +function updateTemplateDropdown() { + const sel = document.getElementById('template-select'); + sel.innerHTML = '' + + state.templates.map(t => ``).join(''); +} + +function populateTemplateSelects(platforms) { + const sel = document.getElementById('tpl-platform'); + sel.innerHTML = '' + + platforms.map(p => ``).join(''); +} + +// --- Inventory / Storage --- +async function loadInventory() { + const locations = await api('GET', '/api/inventory/locations'); + const unassigned = await api('GET', '/api/inventory/unassigned'); + + const statsEl = document.getElementById('inv-stats'); + const totalItems = locations.reduce((s, l) => s + l.count, 0) + unassigned.length; + const totalActive = locations.reduce((s, l) => s + l.active, 0) + unassigned.filter(x => x.status === 'active').length; + const totalValue = locations.reduce((s, l) => s + l.total_value, 0) + unassigned.filter(x => x.status === 'active').reduce((s, x) => s + (x.price || 0), 0); + statsEl.innerHTML = ` +
                          ${totalItems}
                          Artikel gesamt
                          +
                          ${locations.length}
                          Lagerorte
                          +
                          ${totalActive}
                          Aktive Artikel
                          +
                          ${totalValue.toFixed(0)} €
                          Lagerwert
                          `; + + const locsEl = document.getElementById('inv-locations'); + let html = ''; + for (const loc of locations) { + html += `
                          +
                          +
                          +
                          ${escapeHtml(loc.storage_location)}
                          +
                          +
                          + ${loc.count} Artikel + ${loc.active} aktiv + ${loc.sold ? `${loc.sold} verkauft` : ''} + ${loc.reserved ? `${loc.reserved} reserviert` : ''} +
                          +
                          ${loc.total_value.toFixed(0)} €
                          +
                          `; + } + if (unassigned.length) { + html += `
                          +
                          +
                          +
                          Ohne Lagerort
                          +
                          +
                          ${unassigned.length} Artikel
                          +
                          `; + } + if (!html) html = '
                          Noch keine Lagerorte vorhanden. Weise Artikeln einen Lagerort zu.
                          '; + locsEl.innerHTML = html; + + document.getElementById('inv-detail').classList.add('hidden'); + locsEl.classList.remove('hidden'); + + locsEl.querySelectorAll('.inv-location-card').forEach(card => { + card.addEventListener('click', () => showInventoryDetail(card.dataset.location)); + }); + + document.getElementById('inv-add-location-btn').onclick = () => { + const name = prompt('Neuer Lagerort (z.B. Regal A1, Karton 5, Schrank oben):'); + if (!name?.trim()) return; + toast(`Lagerort "${name.trim()}" angelegt. Weise jetzt Artikel zu.`); + loadInventory(); + }; +} + +async function showInventoryDetail(location) { + const items = location + ? await api('GET', `/api/inventory/location/${encodeURIComponent(location)}`) + : await api('GET', '/api/inventory/unassigned'); + + document.getElementById('inv-locations').classList.add('hidden'); + const detail = document.getElementById('inv-detail'); + detail.classList.remove('hidden'); + document.getElementById('inv-detail-title').textContent = location || 'Ohne Lagerort'; + + const list = document.getElementById('inv-detail-list'); + if (!items.length) { + list.innerHTML = '
                          Keine Artikel an diesem Lagerort
                          '; + } else { + list.innerHTML = items.map(l => { + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const statusCls = l.status === 'sold' ? 'sold' : l.status === 'reserved' ? 'reserved' : ''; + return `
                          + +
                          +
                          ${l.sku ? `${escapeHtml(l.sku)}` : ''} ${escapeHtml(l.title || 'Ohne Titel')}
                          +
                          ${pName} · ${l.price ? l.price.toFixed(2) + ' €' : '–'} · ${l.status || 'active'}
                          +
                          +
                          `; + }).join(''); + } + + document.getElementById('inv-back').onclick = () => loadInventory(); + + document.getElementById('inv-rename-btn').onclick = async () => { + if (!location) return toast('Ohne Lagerort kann nicht umbenannt werden'); + const newName = prompt('Lagerort umbenennen:', location); + if (!newName?.trim() || newName.trim() === location) return; + const ids = items.map(l => l.id); + await api('PUT', '/api/inventory/bulk-location', { ids, location: newName.trim() }); + toast(`Umbenannt zu "${newName.trim()}"`); + loadInventory(); + }; + + document.getElementById('inv-move-btn').onclick = async () => { + const checked = [...detail.querySelectorAll('.inv-item-check:checked')].map(c => parseInt(c.dataset.id)); + if (!checked.length) return toast('Bitte Artikel auswählen'); + const dest = prompt('Ziel-Lagerort:'); + if (!dest?.trim()) return; + await api('PUT', '/api/inventory/bulk-location', { ids: checked, location: dest.trim() }); + toast(`${checked.length} Artikel verschoben nach "${dest.trim()}"`); + loadInventory(); + }; +} + +// --- Fee Calculator --- +function setupFeeCalculator() { + document.getElementById('fee-price').addEventListener('input', debounce(updateFeeCalculator, 200)); +} + +async function updateFeeCalculator() { + const price = parseFloat(document.getElementById('fee-price').value) || 0; + if (price <= 0) { document.getElementById('fee-results').innerHTML = ''; return; } + try { + const fees = await api('GET', `/api/fees?price=${price}`); + const c = document.getElementById('fee-results'); + c.innerHTML = Object.entries(fees).map(([id, f]) => { + const name = state.platforms.find(p => p.id === id)?.name || id; + return `
                          +
                          +
                          ${escapeHtml(name)} ${f.seller_type === 'privat' ? 'Privat' : 'Shop'}
                          +
                          ${escapeHtml(f.breakdown || '')}
                          + ${f.note ? `
                          ${escapeHtml(f.note)}
                          ` : ''} +
                          +
                          +
                          ${Number(f.net || 0).toFixed(2)} €
                          +
                          ${Number(f.fees) === 0 ? 'Keine Verkäufergebühr' : `-${Number(f.fees).toFixed(2)} € Gebühren`}
                          +
                          +
                          `; + }).join(''); + } catch {} +} + +// --- Publish Center --- +const publishState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + queue: [], + scheduled: [], + workflow: null, + integrations: { ebay: null, etsy: null }, + selectedId: null, + view: 'smart', + renderToken: 0, + smartCopyToken: 0, +}; + +function setupPublish() { + const search = document.getElementById('publish-search'); + if (!search) return; + + search.addEventListener('input', debounce(loadPublishList, 250)); + document.getElementById('publish-filter-platform')?.addEventListener('change', loadPublishList); + document.getElementById('publish-filter-status')?.addEventListener('change', loadPublishList); + document.getElementById('publish-sort')?.addEventListener('change', () => { + sortPublishListings(); + renderPublishList(); + }); + document.getElementById('publish-select-all')?.addEventListener('change', event => { + for (const listing of publishState.listings) { + if (event.target.checked) publishState.selected.add(listing.id); + else publishState.selected.delete(listing.id); + } + renderPublishList(); + updatePublishToolbar(); + }); + + const platformSelect = document.getElementById('publish-filter-platform'); + platformSelect.innerHTML = '' + + state.platforms.map(platform => ``).join(''); + + document.querySelectorAll('[data-publish-view]').forEach(button => { + button.addEventListener('click', () => { + setPublishView(button.dataset.publishView); + if (button.dataset.publishView === 'smart' && selectedPublishListing()) openSmartCopyModal(selectedPublishListing()); + }); + }); + document.querySelectorAll('[data-status-target]').forEach(button => { + button.addEventListener('click', () => setPublishView(button.dataset.statusTarget)); + }); + + document.getElementById('publish-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-status-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-batch-copy')?.addEventListener('click', batchSmartCopy); + document.getElementById('publish-batch-etsy')?.addEventListener('click', batchEtsyPublish); + document.getElementById('publish-batch-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-add-selected-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-batch-schedule')?.addEventListener('click', openBatchSchedule); + document.getElementById('publish-download-photos')?.addEventListener('click', batchDownloadPhotos); + document.getElementById('publish-ebay-queue-btn')?.addEventListener('click', openEbayQueue); + document.getElementById('publish-schedule-overview')?.addEventListener('click', openScheduleOverview); + document.getElementById('publish-ebay-queue-process')?.addEventListener('click', processEbayQueue); + document.getElementById('publish-ebay-queue-retry')?.addEventListener('click', retryEbayQueue); + + document.getElementById('schedule-modal-close')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-cancel')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-modal')?.addEventListener('click', event => { + if (event.target === event.currentTarget) closePublishSchedule(); + }); + document.querySelectorAll('[data-smart-copy-close]').forEach(button => button.addEventListener('click', closeSmartCopyModal)); + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + if (isSmartCopyModalOpen()) closeSmartCopyModal(); + if (!document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) closeFluxPromptLab(); + }); +} + +async function publishSafeGet(url, fallback, attempts = 1) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt++) { + try { return await api('GET', url); } + catch (error) { + lastError = error; + if (attempt < attempts - 1) await new Promise(resolve => setTimeout(resolve, 220 * (attempt + 1))); + } + } + if (fallback === PUBLISH_REQUIRED) throw lastError || new Error('Daten konnten nicht geladen werden'); + return fallback; +} + +const PUBLISH_REQUIRED = Symbol('publish-required'); + +async function loadPublishListLegacy() { + const search = document.getElementById('publish-search'); + const listContainer = document.getElementById('publish-list'); + if (!search || !listContainer) return; + const renderToken = ++publishState.renderToken; + + const q = search.value.trim(); + const platform = document.getElementById('publish-filter-platform')?.value || ''; + const status = document.getElementById('publish-filter-status')?.value || ''; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + + const refreshButtons = [document.getElementById('publish-refresh'), document.getElementById('publish-status-refresh')]; + const refreshLabels = refreshButtons.map(button => button?.textContent || ''); + refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.textContent = index === 0 ? 'Wird geladen …' : '…'; }); + if (!publishState.listings.length) listContainer.innerHTML = '
                          Artikel werden geladen…
                          '; + + try { + const query = params.toString(); + const [listings, queue, scheduled, workflow, ebayStatus, etsyStatus] = await Promise.all([ + publishSafeGet(`/api/listings${query ? `?${query}` : ''}`, PUBLISH_REQUIRED, 3), + publishSafeGet('/api/ebay/queue', [], 2), + publishSafeGet('/api/schedule', [], 2), + publishSafeGet('/api/dashboard/workflow?limit=4', null, 2), + publishSafeGet('/api/ebay/status', { connected: false, hasCredentials: false }, 2), + publishSafeGet('/api/etsy/status', { connected: false, hasApiKey: false }, 2), + ]); + if (renderToken !== publishState.renderToken) return; + + publishState.allFiltered = Array.isArray(listings) ? listings : []; + publishState.listings = [...publishState.allFiltered]; + publishState.queue = Array.isArray(queue) ? queue : []; + publishState.scheduled = Array.isArray(scheduled) ? scheduled : []; + publishState.workflow = workflow; + publishState.integrations = { ebay: ebayStatus, etsy: etsyStatus }; + + const ids = publishState.listings.map(listing => listing.id); + publishState.publishStatus = ids.length + ? await publishSafeGet(`/api/publish/status?ids=${ids.join(',')}`, {}, 2) + : {}; + if (renderToken !== publishState.renderToken) return; + + sortPublishListings(); + const visibleIds = new Set(publishState.listings.map(listing => listing.id)); + if (!publishState.selectedId || !visibleIds.has(publishState.selectedId)) { + const selectedVisible = [...publishState.selected].find(id => visibleIds.has(id)); + publishState.selectedId = selectedVisible || publishState.listings[0]?.id || null; + } + + renderPublishList(); + renderPublishWorkspace(); + renderPublishStatusRail(); + updatePublishToolbar(); + } catch (error) { + if (renderToken !== publishState.renderToken) return; + toast(`Publish-Center konnte nicht geladen werden: ${error.message}`); + listContainer.innerHTML = `
                          Artikel konnten nicht geladen werden${escapeHtml(error.message)}
                          `; + listContainer.querySelector('[data-publish-retry]')?.addEventListener('click', loadPublishList); + } finally { + if (renderToken === publishState.renderToken) refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = false; button.removeAttribute('aria-busy'); button.textContent = refreshLabels[index]; }); + } +} + +function sortPublishListings() { + const sort = document.getElementById('publish-sort')?.value || 'newest'; + const byDate = value => new Date(value || 0).getTime() || 0; + publishState.listings.sort((a, b) => { + if (sort === 'oldest') return byDate(a.created_at) - byDate(b.created_at); + if (sort === 'price-desc') return (Number(b.price) || 0) - (Number(a.price) || 0); + if (sort === 'price-asc') return (Number(a.price) || 0) - (Number(b.price) || 0); + if (sort === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'de'); + return byDate(b.created_at) - byDate(a.created_at); + }); +} + +function publishLatestStatus(listing) { + const statuses = publishState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return { label: 'Vorbereitet', status: 'prepared', platform: listing.platform }; + const priority = { failed: 0, uploading: 1, pending: 2, copied: 3, published: 4 }; + entries.sort((a, b) => (priority[a[1]] ?? 9) - (priority[b[1]] ?? 9)); + const [platform, status] = entries[0]; + const labels = { failed: 'Fehlgeschlagen', uploading: 'In Bearbeitung', pending: 'Ausstehend', copied: 'Kopiert', published: 'Live' }; + return { label: labels[status] || status, status, platform }; +} + +function renderPublishList() { + const container = document.getElementById('publish-list'); + if (!container) return; + document.getElementById('publish-list-count').textContent = publishState.listings.length; + + if (!publishState.listings.length) { + container.innerHTML = '
                          Keine Artikel gefundenÄndere Suche oder Filter.
                          '; + updatePublishToolbar(); + return; + } + + container.innerHTML = publishState.listings.map(listing => { + const photo = listing.photos?.[0]; + const status = publishLatestStatus(listing); + const selected = listing.id === publishState.selectedId; + const checked = publishState.selected.has(listing.id); + return `
                          + +
                          ${photo ? `` : 'Kein Bild'}
                          +
                          + ${escapeHtml(listing.title || 'Ohne Titel')} + ${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} +
                          ${escapeHtml(platformLabel(listing.platform))}${formatCurrency(listing.price) || 'Preis fehlt'}
                          +
                          + ${escapeHtml(status.label)} +
                          `; + }).join(''); + + container.querySelectorAll('[data-publish-listing-id]').forEach(card => { + card.addEventListener('click', event => { + if (event.target.closest('input,button,a,label')) return; + publishState.selectedId = Number(card.dataset.publishListingId); + renderPublishList(); + renderPublishWorkspace(); + }); + }); + + container.querySelectorAll('[data-publish-check]').forEach(checkbox => { + checkbox.addEventListener('change', event => { + const id = Number(event.target.dataset.publishCheck); + if (event.target.checked) publishState.selected.add(id); + else publishState.selected.delete(id); + if (!publishState.selectedId) publishState.selectedId = id; + updatePublishToolbar(); + }); + }); +} + +function updatePublishToolbar() { + const count = publishState.selected.size; + const selectedLabel = document.getElementById('publish-selected-count'); + if (selectedLabel) selectedLabel.textContent = `${count} ausgewählt`; + ['publish-batch-copy', 'publish-batch-etsy', 'publish-batch-ebay', 'publish-batch-schedule', 'publish-download-photos', 'publish-add-selected-ebay'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + + const visible = publishState.listings; + const selectedVisible = visible.filter(listing => publishState.selected.has(listing.id)).length; + const selectAll = document.getElementById('publish-select-all'); + if (selectAll) { + selectAll.checked = visible.length > 0 && selectedVisible === visible.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length; + } +} + +function setPublishViewLegacy(view) { + publishState.view = ['smart', 'direct', 'queue', 'scheduled'].includes(view) ? view : 'smart'; + document.querySelectorAll('[data-publish-view]').forEach(button => { + const active = button.dataset.publishView === publishState.view; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + renderPublishWorkspace(); +} + +function selectedPublishListing() { + return publishState.listings.find(listing => listing.id === publishState.selectedId) + || publishState.allFiltered.find(listing => listing.id === publishState.selectedId) + || null; +} + +function renderPublishWorkspaceLegacy() { + if (publishState.view === 'queue') { renderQueueWorkspace(); return; } + if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; } + + const listing = selectedPublishListing(); + const workspace = document.getElementById('publish-workspace'); + if (!workspace) return; + if (!listing) { + workspace.innerHTML = `

                          Artikel auswählen

                          Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.

                          `; + return; + } + if (publishState.view === 'direct') renderDirectPublishWorkspace(listing); + else renderSmartCopyLauncher(listing); +} + + +function renderSmartCopyLauncher(listing) { + const workspace = document.getElementById('publish-workspace'); + const target = publishPlatformTarget(listing.platform); + const score = publishReadinessScore(listing); + const tags = Array.isArray(listing.tags) ? listing.tags : String(listing.tags || '').split(',').map(value => value.trim()).filter(Boolean); + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit')} +
                          +
                          +
                          Verkaufsdaten

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

                          +
                          ${escapeHtml(listing.description || 'Noch keine Beschreibung vorhanden.').replace(/\n/g, '
                          ')}
                          +
                          ${tags.length ? tags.slice(0, 12).map(tag => `${escapeHtml(tag)}`).join('') : 'Keine Tags'}
                          +
                          +
                          Plattform${escapeHtml(target.name)}
                          Preis${escapeHtml(formatCurrency(listing.price) || 'Nicht gesetzt')}
                          +
                          Zustand${escapeHtml(listing.condition || 'Nicht gesetzt')}
                          Marke${escapeHtml(listing.brand || 'Nicht gesetzt')}
                          +
                          Kategorie${escapeHtml(listing.category || 'Nicht gesetzt')}
                          Größe${escapeHtml(listing.size || 'Nicht gesetzt')}
                          +
                          +
                          + +
                          `; + bindPublishWorkspaceNavigation(workspace); + workspace.querySelector('[data-open-smart-copy-modal]')?.addEventListener('click', () => openSmartCopyModal(listing)); + window.VendooIcons?.render(workspace); +} + +function isSmartCopyModalOpen() { + return !document.getElementById('smart-copy-modal')?.classList.contains('hidden'); +} + +function closeSmartCopyModal() { + const modal = document.getElementById('smart-copy-modal'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + document.getElementById('smart-copy-modal-content')?.replaceChildren(); + if (document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) document.body.classList.remove('modal-open'); +} + +function openSmartCopyModal(listing = selectedPublishListing()) { + if (!listing) return toast('Bitte zuerst ein Artikel auswählen.'); + const modal = document.getElementById('smart-copy-modal'); + modal?.classList.remove('hidden'); + modal?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + renderSmartCopyWorkspace(listing); +} + +function publishWorkspaceHeader(listing, kicker, stateLabel) { + const photo = listing.photos?.[0]; + return `
                          +
                          +
                          ${photo ? `` : 'Kein Bild'}
                          +
                          ${escapeHtml(kicker)}

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

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

                          +
                          +
                          + ${escapeHtml(stateLabel)} + + +
                          +
                          `; +} + +function bindPublishWorkspaceNavigation(scope = document) { + const currentIndex = publishState.listings.findIndex(listing => listing.id === publishState.selectedId); + scope.querySelector('[data-publish-previous]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex <= 0 ? publishState.listings.length - 1 : currentIndex - 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); + if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace(); + }); + scope.querySelector('[data-publish-next]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex >= publishState.listings.length - 1 ? 0 : currentIndex + 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); + if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace(); + }); +} + +function publishPlatformTarget(platform) { + return ({ + vinted: { name: 'Vinted', url: 'https://www.vinted.de/items/new', direct: false }, + 'ebay-ka': { name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', direct: false }, + 'ebay-de': { name: 'eBay.de', url: 'https://www.ebay.de/sell/create', direct: true }, + etsy: { name: 'Etsy', url: 'https://www.etsy.com/your/shops/me/tools/listings/create', direct: true }, + })[platform] || { name: platformLabel(platform), url: null, direct: false }; +} + +function publishPhotoGrid(photos, limit = 6) { + if (!photos?.length) return '
                          Keine Fotos hinterlegt
                          '; + const visible = photos.slice(0, limit); + return `
                          ${visible.map(photo => ``).join('')}${photos.length > limit ? `+${photos.length - limit} weitere` : ''}
                          `; +} + +async function renderSmartCopyWorkspace(listing) { + const workspace = document.getElementById('smart-copy-modal-content'); + if (!workspace) return; + const token = ++publishState.smartCopyToken; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Wird vorbereitet')}
                          Daten werden vorbereitet …
                          `; + bindPublishWorkspaceNavigation(workspace); + + try { + const data = await api('POST', '/api/publish/smart-copy', { listing_id: listing.id }); + if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return; + const fields = data.fields || {}; + const target = publishPlatformTarget(listing.platform); + const tags = Array.isArray(fields.tags) ? fields.tags.join(', ') : (fields.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit zum Kopieren')} +
                          +
                          +
                          +
                          ${String(fields.title || '').length} Zeichen
                          +
                          +
                          +
                          +
                          + +
                          +
                          +
                          ${tags ? tags.split(',').filter(Boolean).length : 0}
                          +
                          +
                          +
                          + ${publishReadonlyMeta('Preis', fields.price ? formatCurrency(fields.price) : 'Nicht gesetzt', 'publish-smart-price')} + ${publishReadonlyMeta('Marke', fields.brand || '—', 'publish-smart-brand')} + ${publishReadonlyMeta('Kategorie', fields.category || '—', 'publish-smart-category')} + ${publishReadonlyMeta('Größe', fields.size || '—', 'publish-smart-size')} + ${publishReadonlyMeta('Zustand', fields.condition || '—', 'publish-smart-condition')} +
                          +
                          + +
                          +
                          +
                          +
                          ${target.url ? `` : ''}
                          +
                          `; + + bindPublishWorkspaceNavigation(workspace); + bindSmartCopyActions(workspace, listing, data, target, fields, tags); + } catch (error) { + if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}
                          Smart Copy konnte nicht vorbereitet werden

                          ${escapeHtml(error.message)}

                          `; + bindPublishWorkspaceNavigation(workspace); + workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing)); + } +} + +function publishReadonlyMeta(label, value, id) { + return ``; +} + +function bindSmartCopyActions(scope, listing, data, target, fields, tags) { + scope.querySelectorAll('[data-copy-publish-field]').forEach(button => { + button.addEventListener('click', () => copyPublishValue(document.getElementById(button.dataset.copyPublishField)?.value || '', button)); + }); + scope.querySelector('[data-copy-publish-description]')?.addEventListener('click', event => copyPublishValue(fields.description || '', event.currentTarget)); + scope.querySelector('[data-copy-publish-html]')?.addEventListener('click', async event => { + try { + const html = await api('POST', '/api/html-wrap', { listing_id: listing.id, platform: listing.platform }); + copyPublishValue(html.html || fields.description || '', event.currentTarget); + } catch (error) { toast(`HTML konnte nicht erstellt werden: ${error.message}`); } + }); + scope.querySelector('[data-copy-publish-all]')?.addEventListener('click', event => copyPublishValue(`${fields.title || ''}\n\n${fields.description || ''}\n\n${tags}`, event.currentTarget)); + scope.querySelector('[data-open-publish-platform]')?.addEventListener('click', () => { if (target.url) window.open(target.url, '_blank', 'noopener'); }); + scope.querySelector('[data-mark-publish-live]')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + await api('POST', '/api/publish/mark', { listing_id: listing.id, platform: listing.platform, status: 'published' }); + toast('Artikel als veröffentlicht markiert'); + closeSmartCopyModal(); + await loadPublishList(); + } catch (error) { toast(`Fehler: ${error.message}`); } + finally { button.disabled = false; } + }); + scope.querySelector('[data-download-smart-photos]')?.addEventListener('click', () => downloadPublishPhotos(data.photos || [])); + scope.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); }); +} + +function copyPublishValue(value, button) { + navigator.clipboard.writeText(String(value || '')).then(() => { + const old = button?.textContent; + if (button) button.textContent = 'Kopiert'; + toast('In die Zwischenablage kopiert'); + if (button) setTimeout(() => { button.textContent = old; }, 1200); + }).catch(() => toast('Kopieren nicht möglich')); +} + +function publishReadinessItems(listing) { + return [ + { label: 'Titel', ok: Boolean(listing.title?.trim()) }, + { label: 'Beschreibung', ok: Boolean(listing.description?.trim()) }, + { label: 'Preis', ok: Number(listing.price) > 0 }, + { label: 'Bilder', ok: Boolean(listing.photos?.length) }, + { label: 'Kategorie', ok: Boolean(listing.category?.trim()) }, + { label: 'Zustand', ok: Boolean(listing.condition?.trim()) }, + ]; +} + +function publishReadinessScore(listing) { + const items = publishReadinessItems(listing); + return Math.round((items.filter(item => item.ok).length / items.length) * 100); +} + +function renderDirectPublishWorkspace(listing) { + const workspace = document.getElementById('publish-workspace'); + const target = publishPlatformTarget(listing.platform); + const connected = listing.platform === 'ebay-de' + ? Boolean(publishState.integrations.ebay?.connected) + : listing.platform === 'etsy' + ? Boolean(publishState.integrations.etsy?.connected) + : false; + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Direkt veröffentlichen', target.direct ? (connected ? 'Verbindung bereit' : 'Nicht verbunden') : 'Smart Copy erforderlich')} +
                          +
                          +
                          ${String(listing.title || '').length}/80
                          +
                          ${String(listing.description || '').length}/5000
                          +
                          ${Array.isArray(listing.tags) ? listing.tags.length : 0}
                          +
                          + + + + + + +
                          +
                          + +
                          +
                          +
                          +
                          ${listing.platform === 'ebay-de' ? '' : ''}
                          +
                          `; + + bindPublishWorkspaceNavigation(workspace); + const title = document.getElementById('publish-direct-title'); + const description = document.getElementById('publish-direct-description'); + title?.addEventListener('input', () => { document.getElementById('publish-direct-title-count').textContent = `${title.value.length}/80`; }); + description?.addEventListener('input', () => { document.getElementById('publish-direct-description-count').textContent = `${description.value.length}/5000`; }); + workspace.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); }); + workspace.querySelector('[data-publish-save-direct]')?.addEventListener('click', () => saveDirectPublishDraft(listing)); + workspace.querySelector('[data-add-current-ebay]')?.addEventListener('click', async () => { + publishState.selected.add(listing.id); updatePublishToolbar(); await batchEbayAdd(); + }); + workspace.querySelector('[data-publish-direct]')?.addEventListener('click', async event => { + if (!target.direct) { setPublishView('smart'); openSmartCopyModal(listing); return; } + if (!connected) { toast(`${target.name} ist nicht verbunden`); switchToTab('settings'); return; } + const saved = await saveDirectPublishDraft(listing, false); + if (!saved) return; + const button = event.currentTarget; + button.disabled = true; button.textContent = 'Wird eingereiht …'; + try { + if (listing.platform === 'etsy') await api('POST', '/api/publish/etsy', { listing_id: listing.id }); + else await api('POST', `/api/ebay/publish/${listing.id}`); + toast(`Auftrag für ${target.name} wurde sicher eingereiht`); + notify('Vendoo Publish', `${listing.title || listing.sku} wurde zur Publishing-Queue hinzugefügt.`); + await loadPublishList(); + } catch (error) { toast(`Veröffentlichung fehlgeschlagen: ${error.message}`); } + finally { button.disabled = false; button.textContent = 'Jetzt veröffentlichen'; } + }); +} + +async function saveDirectPublishDraft(listing, showSuccess = true) { + const payload = { + title: document.getElementById('publish-direct-title')?.value.trim() || '', + description: document.getElementById('publish-direct-description')?.value.trim() || '', + tags: (document.getElementById('publish-direct-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean), + price: Number.parseFloat(document.getElementById('publish-direct-price')?.value) || null, + condition: document.getElementById('publish-direct-condition')?.value.trim() || null, + brand: document.getElementById('publish-direct-brand')?.value.trim() || null, + size: document.getElementById('publish-direct-size')?.value.trim() || null, + color: document.getElementById('publish-direct-color')?.value.trim() || null, + category: document.getElementById('publish-direct-category')?.value.trim() || null, + }; + try { + const updated = await api('PUT', `/api/listings/${listing.id}`, payload); + const replace = items => items.map(item => item.id === updated.id ? updated : item); + publishState.listings = replace(publishState.listings); + publishState.allFiltered = replace(publishState.allFiltered); + if (showSuccess) toast('Artikel gespeichert'); + return updated; + } catch (error) { + toast(`Speichern fehlgeschlagen: ${error.message}`); + return null; + } +} + +function renderQueueWorkspaceLegacy() { + const workspace = document.getElementById('publish-workspace'); + const queue = publishState.queue; + const counts = publishQueueCounts(); + workspace.innerHTML = `
                          eBay Automatisierung

                          eBay Queue

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

                          +
                          ${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '

                          Queue ist leer

                          Wähle links Artikel aus und füge sie zur eBay Queue hinzu.

                          '}
                          +
                          eBay-Verbindung: ${publishState.integrations.ebay?.connected ? 'Verbunden' : 'Nicht verbunden'}
                          `; + + workspace.querySelector('[data-queue-process]')?.addEventListener('click', processEbayQueue); + workspace.querySelector('[data-queue-retry]')?.addEventListener('click', retryEbayQueue); + workspace.querySelector('[data-queue-clear]')?.addEventListener('click', clearEbayQueueUI); + workspace.querySelector('[data-queue-add-selected]')?.addEventListener('click', batchEbayAdd); + workspace.querySelectorAll('[data-queue-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/ebay/queue/${button.dataset.queueRemove}`); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishQueueRow(item) { + const labels = { pending: 'Ausstehend', uploading: 'In Bearbeitung', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' }; + return `
                          +
                          ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                          ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                          +
                          Status${escapeHtml(labels[item.status] || item.status)}
                          +
                          Versuche${Number(item.retries) || 0}
                          +
                          ${item.error ? `${escapeHtml(item.error)}` : 'Keine Fehler'}
                          +
                          ${item.ebay_url ? `Ansehen` : ''}
                          +
                          `; +} + +function renderScheduledWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const items = publishState.scheduled; + workspace.innerHTML = `
                          Zeitsteuerung

                          Geplante Veröffentlichungen

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

                          +
                          ${items.length ? items.map(item => publishScheduleRow(item)).join('') : '

                          Nichts geplant

                          Plane ausgewählte Artikel für einen späteren Zeitpunkt.

                          '}
                          `; + + workspace.querySelector('[data-schedule-selected]')?.addEventListener('click', openBatchSchedule); + workspace.querySelectorAll('[data-schedule-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/schedule/${button.dataset.scheduleRemove}`); toast('Planung entfernt'); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishScheduleRow(item) { + const statusLabels = { scheduled: 'Geplant', publishing: 'In Bearbeitung', queued: 'An Queue übergeben', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' }; + const date = new Date(String(item.scheduled_at || '').replace(' ', 'T') + (String(item.scheduled_at || '').includes('Z') ? '' : 'Z')); + const dateText = Number.isNaN(date.getTime()) ? item.scheduled_at : date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); + return `
                          +
                          ${escapeHtml(dateText || '—')}${escapeHtml(platformLabel(item.platform))}
                          +
                          ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                          ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                          + ${escapeHtml(statusLabels[item.status] || item.status)} + ${item.error ? `${escapeHtml(item.error)}` : ''} + ${item.status === 'scheduled' ? `` : ''} +
                          `; +} + +function publishQueueCountsLegacy() { + return publishState.queue.reduce((counts, item) => { + if (item.status === 'pending') counts.pending++; + else if (item.status === 'uploading') counts.processing++; + else if (item.status === 'published') counts.published++; + else if (item.status === 'failed') counts.failed++; + return counts; + }, { pending: 0, processing: 0, published: 0, failed: 0 }); +} + +function renderPublishStatusRailLegacy() { + const counts = publishQueueCounts(); + const workflowPublished = publishState.workflow?.stages?.published?.count || 0; + const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length; + const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; }; + setText('publish-status-pending', counts.pending); + setText('publish-status-processing', counts.processing); + setText('publish-status-published', Math.max(counts.published, workflowPublished)); + setText('publish-status-failed', counts.failed); + setText('publish-status-scheduled', publishState.scheduled.length); + setText('publish-direct-count', directEligible); + setText('publish-queue-count', publishState.queue.length); + setText('publish-scheduled-count', publishState.scheduled.length); + setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler warten` : 'Keine Fehler'); + setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen'); + + const activity = publishState.workflow?.activity || []; + const container = document.getElementById('publish-activity'); + if (container) { + container.innerHTML = activity.length ? activity.slice(0, 6).map(item => `
                          ${activityIcon(item.type)}
                          ${escapeHtml(item.text || '')}${relativeTime(item.at)}
                          `).join('') : '
                          Noch keine Publish-Aktivität
                          '; + } +} + +async function openSmartCopy(listingId) { + publishState.selectedId = Number(listingId); + setPublishView('smart'); + renderPublishList(); + openSmartCopyModal(selectedPublishListing()); +} + +async function batchSmartCopy() { + const id = [...publishState.selected][0] || publishState.selectedId; + if (!id) return; + openSmartCopy(id); +} + +async function batchEtsyPublish() { + const ids = [...publishState.selected]; + const eligible = publishState.listings.filter(listing => ids.includes(listing.id) && listing.platform === 'etsy'); + if (!eligible.length) { toast('Keine ausgewählten Etsy-Artikel'); return; } + let succeeded = 0; + let failed = 0; + for (const listing of eligible) { + try { await api('POST', '/api/publish/etsy', { listing_id: listing.id }); succeeded++; } + catch { failed++; } + } + toast(`Etsy: ${succeeded} Auftrag${succeeded === 1 ? '' : 'e'} eingereiht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); +} + +async function batchEbayAddLegacy() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const response = await api('POST', '/api/ebay/queue', { listing_ids: ids }); + toast(`${response.added} Listing${response.added === 1 ? '' : 's'} zur eBay Queue hinzugefügt`); + publishState.view = 'queue'; + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function openEbayQueueLegacy() { setPublishView('queue'); } + +async function processEbayQueueLegacy() { + if (!publishState.integrations.ebay?.connected) { toast('eBay ist nicht verbunden'); switchToTab('settings'); return; } + const buttons = document.querySelectorAll('[data-queue-process], #publish-ebay-queue-process'); + buttons.forEach(button => { button.disabled = true; }); + try { + const response = await api('POST', '/api/ebay/queue/process'); + const succeeded = response.results?.filter(item => item.status === 'published').length || 0; + const failed = response.results?.filter(item => item.status === 'failed').length || 0; + toast(`${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + notify('Vendoo — eBay Queue', `${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Queue-Fehler: ${error.message}`); } + finally { buttons.forEach(button => { button.disabled = false; }); } +} + +async function retryEbayQueueLegacy() { + try { + const response = await api('POST', '/api/ebay/queue/retry'); + toast(`${response.retried} Einträge erneut eingeplant`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function clearEbayQueueUI() { + if (!confirm('eBay Queue wirklich vollständig leeren?')) return; + try { await api('DELETE', '/api/ebay/queue'); toast('eBay Queue geleert'); await loadPublishList(); setPublishView('queue'); } + catch (error) { toast(`Fehler: ${error.message}`); } +} + +let scheduleListingIds = []; + +function openBatchSchedule() { + scheduleListingIds = [...publishState.selected]; + if (!scheduleListingIds.length && publishState.selectedId) scheduleListingIds = [publishState.selectedId]; + if (!scheduleListingIds.length) { toast('Bitte mindestens ein Artikel auswählen'); return; } + openPublishScheduleDialog(scheduleListingIds); +} + +function openSingleSchedule(listingId) { + scheduleListingIds = [Number(listingId)]; + openPublishScheduleDialog(scheduleListingIds); +} + +function openPublishScheduleDialog(ids) { + const now = new Date(Date.now() + 30 * 60 * 1000); + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + const input = document.getElementById('schedule-datetime'); + input.value = local; + input.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); + document.getElementById('schedule-modal-title').textContent = ids.length === 1 ? 'Veröffentlichung planen' : `${ids.length} Veröffentlichungen planen`; + const selected = publishState.listings.filter(listing => ids.includes(listing.id)); + document.getElementById('schedule-listing-summary').innerHTML = selected.slice(0, 4).map(listing => `${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}`).join('') + (selected.length > 4 ? `+${selected.length - 4} weitere` : ''); + document.getElementById('schedule-modal').classList.remove('hidden'); + + document.getElementById('schedule-confirm').onclick = async () => { + const dateTime = input.value; + const platform = document.getElementById('schedule-platform').value; + if (!dateTime) { toast('Bitte Datum und Uhrzeit auswählen'); return; } + const utc = new Date(dateTime).toISOString().replace('T', ' ').slice(0, 19); + let succeeded = 0; + let failed = 0; + for (const id of ids) { + try { await api('POST', '/api/schedule', { listing_id: id, platform, scheduled_at: utc }); succeeded++; } + catch { failed++; } + } + toast(`${succeeded} Veröffentlichung${succeeded === 1 ? '' : 'en'} geplant${failed ? `, ${failed} fehlgeschlagen` : ''}`); + closePublishSchedule(); + publishState.view = 'scheduled'; + await loadPublishList(); + setPublishView('scheduled'); + }; +} + +function closePublishSchedule() { + document.getElementById('schedule-modal')?.classList.add('hidden'); +} + +async function openScheduleOverview() { setPublishView('scheduled'); } + +async function batchDownloadPhotos() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const data = await api('POST', '/api/publish/batch-photos', { listing_ids: ids }); + await downloadPublishPhotos(data.photos || []); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function downloadPublishPhotos(photos) { + const unique = [...new Set((photos || []).filter(Boolean))]; + if (!unique.length) { toast('Keine Fotos vorhanden'); return; } + if (unique.length === 1) { + const photo = unique[0]; + const link = document.createElement('a'); + link.href = photoUrl(photo); + link.download = String(photo).split('/').pop() || 'vendoo-foto.jpg'; + document.body.appendChild(link); + link.click(); + link.remove(); + toast('Foto wird gespeichert'); + return; + } + + try { + const response = await fetch('/api/photos/archive', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ photos: unique }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'ZIP konnte nicht erstellt werden'); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `vendoo-fotos-${new Date().toISOString().slice(0, 10)}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + toast(`${unique.length} Fotos wurden als ZIP gepackt`); + } catch (error) { + toast(`Foto-ZIP fehlgeschlagen: ${error.message}`); + } +} + + + +// --- Central image gallery / media library --- +const MEDIA_SOURCE_LABELS = { flux: 'FLUX', edited: 'Bearbeitet', batch: 'Produktion', ai: 'AI-Bild', listing: 'Artikel' }; + +function formatMediaFileSize(bytes) { + const size = Number(bytes || 0); + if (!size) return 'Größe unbekannt'; + if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`; + return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`; +} + +function renderMediaSummary() { + const summary = state.mediaSummary || { total: 0, sources: {} }; + const sources = summary.sources || {}; + const values = { total: summary.total || 0, flux: sources.flux || 0, edited: sources.edited || 0, batch: sources.batch || 0, ai: sources.ai || 0, listing: sources.listing || 0 }; + for (const [key, value] of Object.entries(values)) document.getElementById(`media-count-${key}`)?.replaceChildren(document.createTextNode(String(value))); + const current = document.getElementById('media-source')?.value || 'all'; + document.querySelectorAll('[data-media-source]').forEach(button => button.classList.toggle('active', button.dataset.mediaSource === current)); +} + +function updateMediaToolbar() { + const count = state.mediaSelected.size; + document.getElementById('media-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`)); + const download = document.getElementById('media-download-selected'); + const remove = document.getElementById('media-delete-selected'); + const factory = document.getElementById('media-send-to-factory'); + if (download) download.disabled = count === 0; + if (remove) remove.disabled = count === 0; + if (factory) factory.disabled = count === 0; + const selectPage = document.getElementById('media-select-page'); + if (selectPage) { + const selectedOnPage = state.mediaItems.filter(item => state.mediaSelected.has(item.image_path)).length; + selectPage.checked = state.mediaItems.length > 0 && selectedOnPage === state.mediaItems.length; + selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.mediaItems.length; + } +} + +function renderMediaPagination() { + const pagination = state.mediaPagination || {}; + const page = Math.max(1, Number(pagination.page || 1)); + const pages = Math.max(1, Number(pagination.total_pages || 1)); + const total = Math.max(0, Number(pagination.total || 0)); + const pageSize = Math.max(1, Number(state.mediaPageSize || 24)); + const first = total ? ((page - 1) * pageSize) + 1 : 0; + const last = total ? Math.min(total, first + state.mediaItems.length - 1) : 0; + document.querySelectorAll('[data-media-pagination]').forEach(paginationRoot => { + paginationRoot.querySelector('[data-media-page-range]')?.replaceChildren(document.createTextNode(total ? `${first}–${last} von ${total} Bildern` : '0 Bilder')); + paginationRoot.querySelector('[data-media-page-info]')?.replaceChildren(document.createTextNode(`von ${pages}`)); + const select = paginationRoot.querySelector('[data-media-page-jump]'); + if (select) { + const signature = `${pages}:${page}`; + if (select.dataset.signature !== signature) { + select.innerHTML = Array.from({ length: pages }, (_, index) => ``).join(''); + select.dataset.signature = signature; + } else select.value = String(page); + } + const prev = paginationRoot.querySelector('[data-media-page-action="prev"]'); + const next = paginationRoot.querySelector('[data-media-page-action="next"]'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; + }); +} + +function mediaSourceBadges(item) { + return (item.sources || [item.source]).map(source => `${escapeHtml(MEDIA_SOURCE_LABELS[source] || source)}`).join(''); +} + +function mediaActionIcon(name, size = 18) { + const map = { open: 'zoom-in', edit: 'pencil', use: 'plus', download: 'download', delete: 'trash-2' }; + return window.VendooIcons?.svg(map[name] || name, { size, className: 'media-action-icon' }) || ''; +} + +function renderMediaLibrary() { + const container = document.getElementById('media-grid'); + if (!container) return; + container.classList.toggle('is-list', state.mediaView === 'list'); + if (!state.mediaItems.length) { + container.innerHTML = '
                          Keine Bilder gefundenFilter anpassen oder im FLUX Studio ein neues Bild erzeugen.
                          '; + container.querySelector('[data-tab-target]')?.addEventListener('click', () => switchToTab('flux-studio')); + updateMediaToolbar(); + return; + } + container.innerHTML = state.mediaItems.map(item => { + const selected = state.mediaSelected.has(item.image_path); + const title = item.title || item.prompt || item.image_path.split('/').pop(); + const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : 'Abmessungen unbekannt'; + const canFavorite = (item.sources || []).includes('flux'); + return `
                          +
                          + + + ${canFavorite ? `` : ''} +
                          +
                          +
                          ${mediaSourceBadges(item)}
                          + ${escapeHtml(title)} + ${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))} + ${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())} +
                          +
                          + + + ${mediaActionIcon('download')}Download + +
                          +
                          `; + }).join(''); + container.querySelectorAll('[data-media-select]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.mediaSelected.add(input.dataset.mediaSelect); else state.mediaSelected.delete(input.dataset.mediaSelect); + input.closest('.media-card')?.classList.toggle('is-selected', input.checked); + updateMediaToolbar(); + })); + container.querySelectorAll('[data-media-preview]').forEach(button => button.addEventListener('click', () => openMediaLightbox(findMediaItem(button.dataset.mediaPreview)))); + container.querySelectorAll('[data-media-edit]').forEach(button => button.addEventListener('click', () => openMediaEditor(findMediaItem(button.dataset.mediaEdit)))); + container.querySelectorAll('[data-media-use]').forEach(button => button.addEventListener('click', () => useMediaInGenerator(findMediaItem(button.dataset.mediaUse)))); + container.querySelectorAll('[data-media-favorite]').forEach(button => button.addEventListener('click', () => toggleMediaFavorite(findMediaItem(button.dataset.mediaFavorite)))); + container.querySelectorAll('[data-media-delete]').forEach(button => button.addEventListener('click', () => deleteMediaItems([button.dataset.mediaDelete]))); + updateMediaToolbar(); + window.VendooIcons?.render(container); +} + +function findMediaItem(imagePath) { + return state.mediaItems.find(item => item.image_path === imagePath) || (state.mediaCurrent?.image_path === imagePath ? state.mediaCurrent : null); +} + +async function loadMediaLibrary({ silent = false } = {}) { + const container = document.getElementById('media-grid'); + if (!silent && container && !state.mediaItems.length) container.innerHTML = '
                          Bildergalerie wird geladen …
                          '; + try { + const params = new URLSearchParams({ + page: String(state.mediaPage), page_size: String(state.mediaPageSize), + source: document.getElementById('media-source')?.value || 'all', + sort: document.getElementById('media-sort')?.value || 'newest', + favorites: document.getElementById('media-favorites-only')?.checked ? '1' : '0', + }); + const search = document.getElementById('media-search')?.value.trim(); + if (search) params.set('q', search); + const result = await api('GET', `/api/media-library?${params}`); + state.mediaItems = result.items || []; + state.mediaPagination = result.pagination || { page: 1, total_pages: 1, total: state.mediaItems.length }; + state.mediaSummary = result.summary || null; + state.mediaPage = Number(state.mediaPagination.page || 1); + state.mediaSelected = new Set([...state.mediaSelected].filter(path => state.mediaItems.some(item => item.image_path === path))); + renderMediaSummary(); renderMediaLibrary(); renderMediaPagination(); + } catch (error) { + if (container) container.innerHTML = `
                          Bildergalerie konnte nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +async function toggleMediaFavorite(item) { + if (!item) return; + try { + const result = await api('PATCH', '/api/media-library/favorite', { image_path: item.image_path, favorite: !item.favorite }); + item.favorite = result.favorite; + if (state.mediaCurrent?.image_path === item.image_path) state.mediaCurrent.favorite = result.favorite; + renderMediaLibrary(); renderMediaLightbox(); + } catch (error) { toast(error.message); } +} + +async function deleteMediaItems(imagePaths) { + const paths = [...new Set((imagePaths || []).filter(Boolean))]; + if (!paths.length) return; + if (!window.confirm(`${paths.length} Bild${paths.length === 1 ? '' : 'er'} endgültig aus der Medienbibliothek und vom Datenträger löschen? In Artikeln verwendete Bilder werden geschützt.`)) return; + try { + const result = await api('DELETE', '/api/media-library/items', { image_paths: paths }); + state.mediaSelected.clear(); + if (state.mediaCurrent && paths.includes(state.mediaCurrent.image_path)) closeMediaLightbox(); + const protectedCount = result.protected?.length || 0; + toast(`${result.deleted || 0} Bild${result.deleted === 1 ? '' : 'er'} gelöscht${protectedCount ? ` · ${protectedCount} Listing-Bild${protectedCount === 1 ? '' : 'er'} geschützt` : ''}.`); + await loadMediaLibrary(); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function useMediaInGenerator(item) { + if (!item?.image_path) return; + if (!state.photos.includes(item.image_path)) state.photos.push(item.image_path); + renderPhotoPreview(); + switchToTab('generator'); + toast('Bild wurde dem neuen Artikel hinzugefügt.'); +} + +function openMediaEditor(item) { + if (!item) return; + state.mediaCurrent = item; + if ((item.sources || []).includes('flux') && item.flux_id) { + state.fluxStudioCurrent = { ...item, id: item.flux_id, image_path: item.image_path, public_url: item.public_url }; + openPhotoEditor(0, 'flux'); + } else openPhotoEditor(0, 'media'); +} + +const mediaZoomState = { zoom: 1, fitZoom: 1, naturalWidth: 0, naturalHeight: 0, dragging: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 }; + +function updateMediaZoom(zoom, { center = false } = {}) { + const stage = document.getElementById('media-lightbox-stage'); + const canvas = document.getElementById('media-lightbox-canvas'); + const image = document.getElementById('media-lightbox-image'); + if (!stage || !canvas || !image || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return; + const previousCenterX = stage.scrollLeft + stage.clientWidth / 2; + const previousCenterY = stage.scrollTop + stage.clientHeight / 2; + const previousWidth = Math.max(1, mediaZoomState.naturalWidth * mediaZoomState.zoom); + const previousHeight = Math.max(1, mediaZoomState.naturalHeight * mediaZoomState.zoom); + mediaZoomState.zoom = Math.min(4, Math.max(.1, Number(zoom) || 1)); + const width = Math.max(1, Math.round(mediaZoomState.naturalWidth * mediaZoomState.zoom)); + const height = Math.max(1, Math.round(mediaZoomState.naturalHeight * mediaZoomState.zoom)); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + image.style.width = '100%'; + image.style.height = '100%'; + const range = document.getElementById('media-zoom-range'); + const output = document.getElementById('media-zoom-value'); + if (range) range.value = String(Math.round(mediaZoomState.zoom * 100)); + if (output) output.value = output.textContent = `${Math.round(mediaZoomState.zoom * 100)} %`; + if (center) { + const ratioX = previousCenterX / previousWidth; + const ratioY = previousCenterY / previousHeight; + requestAnimationFrame(() => { + stage.scrollLeft = Math.max(0, ratioX * width - stage.clientWidth / 2); + stage.scrollTop = Math.max(0, ratioY * height - stage.clientHeight / 2); + }); + } +} + +function fitMediaLightbox() { + const stage = document.getElementById('media-lightbox-stage'); + if (!stage || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return; + const availableWidth = Math.max(120, stage.clientWidth - 28); + const availableHeight = Math.max(120, stage.clientHeight - 28); + mediaZoomState.fitZoom = Math.min(1, availableWidth / mediaZoomState.naturalWidth, availableHeight / mediaZoomState.naturalHeight); + updateMediaZoom(mediaZoomState.fitZoom); + requestAnimationFrame(() => { stage.scrollLeft = 0; stage.scrollTop = 0; }); +} + +function renderMediaLightbox() { + const item = state.mediaCurrent; + const dialog = document.getElementById('media-lightbox'); + if (!dialog || dialog.classList.contains('hidden') || !item) return; + document.getElementById('media-lightbox-title').textContent = item.title || item.prompt || item.image_path.split('/').pop(); + const image = document.getElementById('media-lightbox-image'); + image.onload = () => { + mediaZoomState.naturalWidth = image.naturalWidth || Number(item.width) || 1; + mediaZoomState.naturalHeight = image.naturalHeight || Number(item.height) || 1; + fitMediaLightbox(); + }; + image.src = item.public_url || photoUrl(item.image_path); + document.getElementById('media-lightbox-download').href = item.public_url || photoUrl(item.image_path); + document.getElementById('media-lightbox-download').download = item.image_path.split('/').pop(); + const favorite = document.getElementById('media-lightbox-favorite'); + const canFavorite = (item.sources || []).includes('flux'); + favorite?.classList.toggle('hidden', !canFavorite); + if (favorite) favorite.innerHTML = `${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 17 }) || ''} ${item.favorite ? 'Favorit entfernen' : 'Favorit'}`; + document.getElementById('media-lightbox-meta').innerHTML = ` +
                          ${mediaSourceBadges(item)}
                          +
                          Datei
                          ${escapeHtml(item.image_path)}
                          Abmessungen
                          ${item.width && item.height ? `${item.width} × ${item.height}` : 'Unbekannt'}
                          Dateigröße
                          ${escapeHtml(formatMediaFileSize(item.file_size))}
                          Format
                          ${escapeHtml(String(item.format || '—').toUpperCase())}
                          Erstellt
                          ${escapeHtml(formatFluxDate(item.created_at))}
                          ${item.seed != null ? `
                          Seed
                          ${item.seed}
                          ` : ''}
                          + ${item.prompt ? `
                          Prompt

                          ${escapeHtml(item.prompt)}

                          ` : ''}`; + window.VendooIcons?.render(dialog); +} + +function openMediaLightbox(item) { + if (!item) return; + state.mediaCurrent = item; + const dialog = document.getElementById('media-lightbox'); + dialog?.classList.remove('hidden'); + dialog?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + mediaZoomState.zoom = 1; + renderMediaLightbox(); +} + +function closeMediaLightbox() { + const dialog = document.getElementById('media-lightbox'); + dialog?.classList.add('hidden'); + dialog?.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('modal-open'); + const image = document.getElementById('media-lightbox-image'); + if (image) image.onload = null; +} + +function setupMediaLibrary() { + const root = document.getElementById('media-library'); + if (!root) return; + const resetAndLoad = () => { state.mediaPage = 1; state.mediaSelected.clear(); loadMediaLibrary(); }; + document.getElementById('media-refresh')?.addEventListener('click', () => loadMediaLibrary()); + document.getElementById('media-source')?.addEventListener('change', resetAndLoad); + document.getElementById('media-sort')?.addEventListener('change', resetAndLoad); + document.getElementById('media-favorites-only')?.addEventListener('change', resetAndLoad); + document.getElementById('media-page-size')?.addEventListener('change', event => { state.mediaPageSize = Number(event.target.value) || 24; resetAndLoad(); }); + document.getElementById('media-search')?.addEventListener('input', debounce(resetAndLoad, 280)); + document.querySelectorAll('[data-media-source]').forEach(button => button.addEventListener('click', () => { + const select = document.getElementById('media-source'); if (select) select.value = button.dataset.mediaSource; resetAndLoad(); + })); + document.getElementById('media-view-grid')?.addEventListener('click', () => { state.mediaView = 'grid'; document.getElementById('media-view-grid').classList.add('active'); document.getElementById('media-view-list').classList.remove('active'); renderMediaLibrary(); }); + document.getElementById('media-view-list')?.addEventListener('click', () => { state.mediaView = 'list'; document.getElementById('media-view-list').classList.add('active'); document.getElementById('media-view-grid').classList.remove('active'); renderMediaLibrary(); }); + document.getElementById('media-select-page')?.addEventListener('change', event => { for (const item of state.mediaItems) { if (event.target.checked) state.mediaSelected.add(item.image_path); else state.mediaSelected.delete(item.image_path); } renderMediaLibrary(); }); + document.getElementById('media-download-selected')?.addEventListener('click', () => downloadPublishPhotos([...state.mediaSelected])); + document.getElementById('media-delete-selected')?.addEventListener('click', () => deleteMediaItems([...state.mediaSelected])); + root.querySelectorAll('[data-media-page-action]').forEach(button => button.addEventListener('click', () => { + const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1)); + const target = button.dataset.mediaPageAction === 'prev' ? state.mediaPage - 1 : state.mediaPage + 1; + if (target >= 1 && target <= pages && target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); } + })); + root.querySelectorAll('[data-media-page-jump]').forEach(select => select.addEventListener('change', () => { + const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1)); + const target = Math.min(pages, Math.max(1, Number(select.value || 1))); + if (target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); } + })); + document.querySelectorAll('[data-media-lightbox-close]').forEach(button => button.addEventListener('click', closeMediaLightbox)); + document.getElementById('media-lightbox-edit')?.addEventListener('click', () => { closeMediaLightbox(); openMediaEditor(state.mediaCurrent); }); + document.getElementById('media-lightbox-favorite')?.addEventListener('click', () => toggleMediaFavorite(state.mediaCurrent)); + document.getElementById('media-lightbox-delete')?.addEventListener('click', () => deleteMediaItems(state.mediaCurrent ? [state.mediaCurrent.image_path] : [])); + document.getElementById('media-zoom-in')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom + .15, { center: true })); + document.getElementById('media-zoom-out')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom - .15, { center: true })); + document.getElementById('media-zoom-fit')?.addEventListener('click', fitMediaLightbox); + document.getElementById('media-zoom-original')?.addEventListener('click', () => updateMediaZoom(1, { center: true })); + document.getElementById('media-zoom-range')?.addEventListener('input', event => updateMediaZoom(Number(event.target.value) / 100, { center: true })); + const zoomStage = document.getElementById('media-lightbox-stage'); + zoomStage?.addEventListener('wheel', event => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + updateMediaZoom(mediaZoomState.zoom + (event.deltaY < 0 ? .1 : -.1), { center: true }); + }, { passive: false }); + zoomStage?.addEventListener('pointerdown', event => { + if (event.button !== 0 || mediaZoomState.zoom <= mediaZoomState.fitZoom + .01) return; + mediaZoomState.dragging = true; mediaZoomState.startX = event.clientX; mediaZoomState.startY = event.clientY; + mediaZoomState.scrollLeft = zoomStage.scrollLeft; mediaZoomState.scrollTop = zoomStage.scrollTop; + zoomStage.setPointerCapture(event.pointerId); zoomStage.classList.add('is-dragging'); + }); + zoomStage?.addEventListener('pointermove', event => { + if (!mediaZoomState.dragging) return; + zoomStage.scrollLeft = mediaZoomState.scrollLeft - (event.clientX - mediaZoomState.startX); + zoomStage.scrollTop = mediaZoomState.scrollTop - (event.clientY - mediaZoomState.startY); + }); + const stopZoomDrag = event => { mediaZoomState.dragging = false; zoomStage?.classList.remove('is-dragging'); try { zoomStage?.releasePointerCapture(event.pointerId); } catch {} }; + zoomStage?.addEventListener('pointerup', stopZoomDrag); zoomStage?.addEventListener('pointercancel', stopZoomDrag); + document.addEventListener('keydown', event => { + if (document.getElementById('media-lightbox')?.classList.contains('hidden')) return; + if (event.key === 'Escape') closeMediaLightbox(); + if (event.key === '+' || event.key === '=') updateMediaZoom(mediaZoomState.zoom + .15, { center: true }); + if (event.key === '-') updateMediaZoom(mediaZoomState.zoom - .15, { center: true }); + if (event.key === '0') fitMediaLightbox(); + }); +} + +// --- Browser extensions --- +const EXTENSION_BROWSERS = { + chrome: { name: 'Google Chrome', icon: 'chrome', managementUrl: 'chrome://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' }, + edge: { name: 'Microsoft Edge', icon: 'edge', managementUrl: 'edge://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' }, + firefox: { name: 'Mozilla Firefox', icon: 'firefox', managementUrl: 'about:debugging#/runtime/this-firefox', action: 'Temporäres Add-on laden', note: 'Für eine dauerhaft signierte Installation muss das Add-on über Mozilla signiert werden.' }, + safari: { name: 'Apple Safari', icon: 'compass', managementUrl: 'Safari → Einstellungen → Erweiterungen', action: 'Safari Web Extension erstellen', note: 'Benötigt macOS und Xcode. Vendoo liefert WebExtension-Quelle und Konvertierungsskript mit.' }, +}; + +function detectActiveBrowser() { + const ua = navigator.userAgent || ''; + if (/Edg\//.test(ua)) return 'edge'; + if (/Firefox\//.test(ua)) return 'firefox'; + if (/Safari\//.test(ua) && !/Chrome\//.test(ua) && !/Chromium\//.test(ua)) return 'safari'; + if (/Chrome\//.test(ua) || /Chromium\//.test(ua)) return 'chrome'; + return 'chrome'; +} + +async function copyTextSafe(value) { + try { await navigator.clipboard.writeText(String(value || '')); return true; } + catch { + const area = document.createElement('textarea'); area.value = String(value || ''); area.style.position = 'fixed'; area.style.opacity = '0'; + document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok; + } +} + +async function prepareBrowserExtension(browser, data = {}) { + const config = EXTENSION_BROWSERS[browser]; + if (!config) return; + try { + const result = await api('POST', '/api/extensions/prepare', { browser }); + await copyTextSafe(result.path || ''); + const card = document.querySelector(`[data-extension-browser="${browser}"]`); + card?.querySelector('.extension-path code')?.replaceChildren(document.createTextNode(result.path || '')); + toast(result.opened + ? `Installationsordner geöffnet und Pfad kopiert. Öffne jetzt ${config.managementUrl}.` + : `Ordnerpfad kopiert: ${result.path}`); + return result; + } catch (error) { + toast(`Extension konnte nicht vorbereitet werden: ${error.message}`); + throw error; + } +} + +function renderExtensions(data = {}) { + const grid = document.getElementById('extensions-grid'); + if (!grid) return; + const active = detectActiveBrowser(); + const activeBrowser = EXTENSION_BROWSERS[active]; + const activeBox = document.getElementById('extensions-active-browser'); + if (activeBox) activeBox.innerHTML = `Aktiver Browser${window.VendooIcons?.svg(activeBrowser.icon, { size: 19 }) || ''} ${escapeHtml(activeBrowser.name)}`; + const version = data.version || '1.9.0'; + grid.innerHTML = Object.entries(EXTENSION_BROWSERS).map(([id, browser]) => { + const info = data.browsers?.[id] || {}; + return `
                          +
                          ${window.VendooIcons?.svg(browser.icon, { size: 27 }) || ''}
                          ${id === active ? 'Aktiver Browser' : 'Verfügbar'}

                          ${escapeHtml(browser.name)}

                          v${escapeHtml(version)}
                          +

                          ${escapeHtml(browser.note)}

                          +
                          Installationsordner${escapeHtml(info.path || 'Wird vom Server ermittelt …')}
                          +
                          1. Installation vorbereiten
                          2. ${escapeHtml(browser.managementUrl)} öffnen
                          3. ${escapeHtml(browser.action)}
                          +
                          + + + ${window.VendooIcons?.svg('download', { size: 17 }) || ''} +
                          + ${id === 'safari' ? 'Safari lässt Erweiterungen nur über eine signierte macOS-App zu. Das Paket enthält dafür ein Xcode-Konvertierungsskript.' : 'Die finale Bestätigung erfolgt im Browser; eine stille automatische Installation ist aus Sicherheitsgründen gesperrt.'} +
                          `; + }).join(''); + grid.querySelectorAll('[data-extension-prepare]').forEach(button => button.addEventListener('click', async () => { + const browser = button.dataset.extensionPrepare; + const old = button.innerHTML; button.disabled = true; button.textContent = 'Wird vorbereitet …'; + try { await prepareBrowserExtension(browser, data); } catch {} + finally { button.disabled = false; button.innerHTML = old; } + })); + grid.querySelectorAll('[data-extension-copy-path]').forEach(button => button.addEventListener('click', async () => { + const path = data.browsers?.[button.dataset.extensionCopyPath]?.path || button.closest('.extension-card')?.querySelector('.extension-path code')?.textContent || ''; + await copyTextSafe(path); toast('Ordnerpfad kopiert'); + })); + window.VendooIcons?.render(document.getElementById('extensions')); +} + +async function loadExtensions() { + const grid = document.getElementById('extensions-grid'); + if (grid && !grid.children.length) grid.innerHTML = '
                          Extensions werden geprüft …
                          '; + try { renderExtensions(await api('GET', '/api/extensions/info')); } + catch (error) { if (grid) grid.innerHTML = `
                          Extension-Informationen konnten nicht geladen werden: ${escapeHtml(error.message)}
                          `; } +} + +function setupExtensions() { + window.VendooIcons?.render(document.getElementById('extensions') || document); + document.getElementById('extensions-prepare-active')?.addEventListener('click', async event => { + const button = event.currentTarget; + const browser = detectActiveBrowser(); + const old = button.innerHTML; + button.disabled = true; + button.textContent = 'Wird vorbereitet …'; + try { await prepareBrowserExtension(browser); } catch {} + finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); } + }); +} + +// --- Settings --- +async function detectPublicBaseUrl({ apply = true } = {}) { + const input = document.getElementById('setting-public-base-url'); + const status = document.getElementById('public-base-url-status'); + const button = document.getElementById('detect-public-base-url-btn'); + if (button) button.disabled = true; + if (status) status.textContent = 'LAN-Adresse wird erkannt …'; + try { + const info = await api('GET', '/api/network-info'); + const preferred = info.public_base_url || info.preferred_url || ''; + if (apply && input && preferred) input.value = preferred; + const adapter = info.addresses?.[0]; + if (status) { + status.textContent = adapter + ? `Erkannt: ${preferred} · Adapter: ${adapter.name}` + : `Keine LAN-IP erkannt. Aktuelle Adresse: ${preferred || 'localhost'}`; + } + return info; + } catch (err) { + if (status) status.textContent = `LAN-Adresse konnte nicht erkannt werden: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + + +function renderLocalFluxInstallState(install = {}) { + const wrap = document.getElementById('local-flux-progress-wrap'); + const label = document.getElementById('local-flux-progress-label'); + const value = document.getElementById('local-flux-progress-value'); + const bar = document.getElementById('local-flux-progress-bar'); + const phase = document.getElementById('local-flux-phase-label'); + const updated = document.getElementById('local-flux-updated-at'); + const checksWrap = document.getElementById('local-flux-checks'); + const errorWrap = document.getElementById('local-flux-error'); + const logTail = document.getElementById('local-flux-log-tail'); + const sevenZipTail = document.getElementById('local-flux-7zip-tail'); + const installButton = document.getElementById('install-local-image-provider-btn'); + const installerState = document.getElementById('local-flux-installer-state'); + const elapsed = document.getElementById('local-flux-elapsed'); + const extractFiles = document.getElementById('local-flux-extract-files'); + const extractSize = document.getElementById('local-flux-extract-size'); + const heartbeat = document.getElementById('local-flux-heartbeat'); + + const progress = Math.max(0, Math.min(100, Number(install.progress ?? install.readiness ?? 0))); + if (wrap) wrap.classList.remove('hidden'); + if (label) label.textContent = install.message || install.phase_label || 'Lokale FLUX-Installation'; + if (value) value.textContent = `${progress}%`; + if (bar) { + bar.style.width = `${progress}%`; + bar.classList.toggle('is-stalled', install.status === 'stalled'); + bar.classList.toggle('is-failed', install.status === 'failed'); + } + if (phase) { + const phaseText = install.installation_complete ? 'Vollständig installiert' : (install.phase_label || install.phase || 'Nicht installiert'); + phase.textContent = `${phaseText} · ${Number(install.readiness || 0)}% geprüft`; + } + if (updated) { + const date = install.updated_at ? new Date(install.updated_at) : null; + updated.textContent = date && !Number.isNaN(date.getTime()) ? `Stand: ${date.toLocaleString('de-DE')}` : 'Stand: –'; + } + + const metrics = install.extract_metrics || {}; + if (installerState) { + const labels = { running: 'Läuft', completed: 'Fertig', failed: 'Fehler', stalled: 'Hängt', 'not-started': 'Nicht gestartet', unknown: 'Unbekannt' }; + installerState.textContent = labels[install.status] || install.status || 'Bereit'; + installerState.className = `local-flux-state-value is-${escapeHtml(install.status || 'idle')}`; + } + if (elapsed) elapsed.textContent = `${Number(install.elapsed_seconds || metrics.elapsed_seconds || 0)} s`; + if (extractFiles) extractFiles.textContent = `${Number(metrics.file_count || 0)} Dateien`; + if (extractSize) extractSize.textContent = `${Number(metrics.size_mb || 0)} MB`; + if (heartbeat) { + heartbeat.textContent = install.heartbeat_age_seconds === null || install.heartbeat_age_seconds === undefined + ? '–' + : `${Number(install.heartbeat_age_seconds)} s`; + } + + const checkLabels = { + seven_zip: '7-Zip', + archive: 'ComfyUI-Paket', + embedded_python: 'Python Runtime', + comfyui: 'ComfyUI', + model: 'FLUX-Modell', + workflow: 'Workflow', + start_script: 'Startskript', + stop_script: 'Stoppskript', + }; + if (checksWrap) { + const checks = install.checks || {}; + checksWrap.innerHTML = Object.entries(checkLabels).map(([key, title]) => { + const check = checks[key] || {}; + let detail = check.ok ? 'Vorhanden' : 'Fehlt'; + if (check.size_gb) detail += ` · ${check.size_gb} GB`; + else if (check.size_mb) detail += ` · ${check.size_mb} MB`; + return `
                          ${escapeHtml(title)}${escapeHtml(detail)}
                          `; + }).join(''); + } + + if (errorWrap) { + const failed = install.status === 'failed' || install.status === 'stalled'; + errorWrap.classList.toggle('hidden', !failed); + errorWrap.textContent = failed + ? `${install.message || 'Installation fehlgeschlagen.'}${install.error_position ? ` +${install.error_position}` : ''} +Die vorhandenen Downloads bleiben erhalten. „Installation fortsetzen“ startet nur den fehlenden Schritt erneut.` + : ''; + } + if (logTail) { + const lines = Array.isArray(install.log_tail) ? install.log_tail : []; + logTail.textContent = lines.length ? lines.join('\n') : 'Noch kein Installationsprotokoll vorhanden.'; + } + if (sevenZipTail) { + const lines = [ + ...(Array.isArray(install.seven_zip_error_tail) ? install.seven_zip_error_tail : []), + ...(Array.isArray(install.seven_zip_output_tail) ? install.seven_zip_output_tail : []), + ]; + sevenZipTail.textContent = lines.length ? lines.slice(-40).join('\n') : 'Noch keine 7-Zip-Ausgabe vorhanden.'; + } + + const healthInstall = document.getElementById('local-flux-health-install'); + const healthModel = document.getElementById('local-flux-health-model'); + if (healthInstall) healthInstall.textContent = install.installation_complete ? 'Vollständig' : install.checks?.comfyui?.ok ? 'Unvollständig' : 'Nicht installiert'; + if (healthModel) healthModel.textContent = install.checks?.model?.ok ? 'Vorhanden' : 'Fehlt'; + + if (installButton) { + if (install.status === 'failed' || install.status === 'stalled') installButton.textContent = 'Installation fortsetzen'; + else if (install.checks?.comfyui?.ok && !install.checks?.model?.ok) installButton.textContent = 'FLUX-Modell nachinstallieren'; + else if (install.can_resume) installButton.textContent = 'Installation fortsetzen'; + else if (install.status === 'completed') installButton.textContent = 'Installation prüfen / ergänzen'; + else installButton.textContent = 'Lokales FLUX installieren'; + } +} + +async function getDetailedLocalFluxInstallStatus() { + try { + const install = await api('GET', '/api/local-image/install-status'); + renderLocalFluxInstallState(install); + return install; + } catch (err) { + const status = document.getElementById('local-image-provider-status'); + if (status) status.textContent = `Installationsstatus konnte nicht gelesen werden: ${err.message}`; + return null; + } +} + +let localFluxStatusTimer = null; +let localFluxStatusWatchActive = false; + +async function watchLocalFluxInstallStatus() { + if (localFluxStatusWatchActive) return; + localFluxStatusWatchActive = true; + const run = async () => { + try { + const install = await getDetailedLocalFluxInstallStatus(); + const delay = install && ['running', 'stalled'].includes(install.status) ? 2000 : 10000; + localFluxStatusTimer = setTimeout(run, delay); + } catch { + localFluxStatusTimer = setTimeout(run, 10000); + } + }; + run(); +} + +async function pollLocalFluxInstall(startedAt = Date.now()) { + const status = document.getElementById('local-image-provider-status'); + const pollStarted = Date.now(); + while (Date.now() - pollStarted < 60 * 60 * 1000) { + const install = await getDetailedLocalFluxInstallStatus(); + if (!install) return null; + if (status) status.textContent = install.message || 'Lokale FLUX-Installation läuft ...'; + + const updatedAt = Date.parse(install.updated_at || '') || 0; + const isFreshTerminal = ['completed', 'failed'].includes(install.status) && (updatedAt >= startedAt - 2000 || Date.now() - pollStarted > 8000); + if (isFreshTerminal) { + if (install.status === 'completed') await testLocalImageProvider({ skipInstallFetch: true }); + else if (status) status.textContent = `Installation fehlgeschlagen: ${install.message || 'Unbekannter Fehler'}`; + return install; + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + if (status) status.textContent = 'Die Installation läuft möglicherweise weiter. Nutze „Status prüfen“, um den aktuellen Stand zu laden.'; + return null; +} + + +function updateLocalFluxCommandCenter(result = {}, install = {}) { + const stateName = result.engine_state || (!result.enabled ? 'disabled' : result.reachable ? 'running' : install.installation_complete ? 'stopped' : 'not-installed'); + const labels = { + running: ['Aktiv und einsatzbereit', 'ComfyUI läuft. Vendoo kann lokale AI-Bilder erzeugen.'], + stopped: ['Installiert, aber gestoppt', 'Ein Klick auf „Server starten“ aktiviert die lokale Bild-Engine.'], + disabled: ['Deaktiviert', 'Vendoo nutzt lokales FLUX aktuell nicht. Installation und Dateien bleiben erhalten.'], + 'not-installed': ['Nicht installiert', 'Installiere ComfyUI und FLUX direkt hier – Vendoo führt dich automatisch durch den Vorgang.'], + }; + const [title, detail] = labels[stateName] || labels['not-installed']; + const orb = document.getElementById('local-flux-orb'); + const titleEl = document.getElementById('local-flux-engine-title'); + const detailEl = document.getElementById('local-flux-engine-detail'); + const badge = document.getElementById('local-flux-engine-badge'); + const enableLabel = document.getElementById('local-flux-enable-label'); + const serverHealth = document.getElementById('local-flux-health-server'); + const referenceHealth = document.getElementById('local-flux-health-reference'); + const heroText = document.getElementById('settings-flux-status-text'); + const heroChip = document.getElementById('settings-flux-status-chip'); + if (titleEl) titleEl.textContent = title; + if (detailEl) detailEl.textContent = detail; + if (badge) { badge.textContent = title; badge.className = `flux-state-badge is-${stateName}`; } + if (orb) orb.className = `flux-orb is-${stateName}`; + if (enableLabel) enableLabel.textContent = result.enabled ? 'Aktiviert' : 'Deaktiviert'; + if (serverHealth) serverHealth.textContent = result.reachable ? 'Online' : install.installation_complete ? 'Gestoppt' : 'Nicht verfügbar'; + if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : 'Aktiv'; + if (heroText) heroText.textContent = title; + if (heroChip) heroChip.className = `settings-status-chip is-${stateName}`; + + const startBtn = document.getElementById('start-local-image-provider-btn'); + const stopBtn = document.getElementById('stop-local-image-provider-btn'); + const uninstallBtn = document.getElementById('uninstall-local-image-provider-btn'); + if (startBtn) startBtn.disabled = !install.installation_complete || result.reachable || !result.enabled; + if (stopBtn) stopBtn.disabled = !result.reachable; + if (uninstallBtn) uninstallBtn.disabled = !install.checks?.comfyui?.ok && !install.checks?.model?.ok; +} + +async function setLocalFluxEnabled(enabled) { + const toggle = document.getElementById('setting-ai-model-photo-local-enabled'); + const status = document.getElementById('local-image-provider-status'); + if (toggle) toggle.disabled = true; + try { + await api('PUT', '/api/settings', { ai_model_photo_local_enabled: enabled ? '1' : '0' }); + if (status) status.textContent = enabled ? 'Lokales FLUX wurde aktiviert.' : 'Lokales FLUX wurde deaktiviert.'; + if (!enabled) { + try { await api('POST', '/api/local-image/stop', {}); } catch {} + } else { + const current = await api('GET', '/api/local-image/status'); + if (current.install?.installation_complete && !current.reachable) { + try { await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); } catch {} + await new Promise(resolve => setTimeout(resolve, 3500)); + } + } + await testLocalImageProvider(); + } catch (err) { + if (toggle) toggle.checked = !enabled; + if (status) status.textContent = `Aktivierung konnte nicht geändert werden: ${err.message}`; + } finally { + if (toggle) toggle.disabled = false; + } +} + +async function uninstallLocalImageProvider() { + const button = document.getElementById('uninstall-local-image-provider-btn'); + const status = document.getElementById('local-image-provider-status'); + const confirmed = window.confirm('Lokales FLUX wirklich deinstallieren? ComfyUI und das große FLUX-Modell werden entfernt. Deine Vendoo-Daten und heruntergeladenen Installationsarchive bleiben erhalten.'); + if (!confirmed) return; + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/uninstall', { remove_downloads: false }); + if (status) status.textContent = result.message || 'Deinstallation wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 2500)); + const started = Date.now(); + while (Date.now() - started < 120000) { + const install = await getDetailedLocalFluxInstallStatus(); + if (install && !install.checks?.comfyui?.ok && !install.checks?.model?.ok) break; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + const toggle = document.getElementById('setting-ai-model-photo-local-enabled'); + if (toggle) toggle.checked = false; + await testLocalImageProvider(); + toast('Lokales FLUX wurde deinstalliert.'); + } catch (err) { + if (status) status.textContent = `Deinstallation fehlgeschlagen: ${err.message}`; + } finally { + if (button) button.disabled = false; + } +} + +function setupSettingsNavigation() { + const buttons = [...document.querySelectorAll('[data-settings-panel]')]; + const panels = [...document.querySelectorAll('[data-settings-panel-content]')]; + const activate = (name) => { + buttons.forEach(button => button.classList.toggle('active', button.dataset.settingsPanel === name)); + panels.forEach(panel => panel.classList.toggle('active', panel.dataset.settingsPanelContent === name)); + try { localStorage.setItem('vendoo-settings-panel', name); } catch {} + }; + buttons.forEach(button => button.addEventListener('click', () => activate(button.dataset.settingsPanel))); + let initial = 'general'; + try { initial = localStorage.getItem('vendoo-settings-panel') || initial; } catch {} + if (!panels.some(panel => panel.dataset.settingsPanelContent === initial)) initial = 'general'; + activate(initial); +} + +function updateReferenceStrengthUi() { + const range = document.getElementById('setting-ai-model-photo-reference-strength'); + const output = document.getElementById('setting-ai-model-photo-reference-strength-value'); + if (range && output) output.textContent = `${range.value}%`; + const referenceHealth = document.getElementById('local-flux-health-reference'); + if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : `${range?.value || 70}%`; +} + +async function testLocalImageProvider(options = {}) { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('test-local-image-provider-btn'); + if (button) button.disabled = true; + if (status) status.textContent = 'Lokaler FLUX/ComfyUI-Status wird geprüft ...'; + try { + const result = await api('GET', '/api/local-image/status'); + const install = options.skipInstallFetch ? (result.install || {}) : (await getDetailedLocalFluxInstallStatus() || result.install || {}); + if (options.skipInstallFetch) renderLocalFluxInstallState(install); + updateLocalFluxCommandCenter(result, install); + if (status) { + if (install.status === 'failed') { + status.textContent = `Installation fehlgeschlagen · ${install.message || 'Details im Installationsstatus'}`; + } else if (result.reachable) { + status.textContent = `Aktiv · ${result.url} erreichbar · Modell und Workflow bereit${result.reference_image_supported ? ' · Produktreferenz unterstützt' : ''}`; + } else { + status.textContent = install.installation_complete ? 'Installiert, aber der Bildserver ist gestoppt.' : `Noch nicht vollständig installiert · ${install.phase_label || 'Installation erforderlich'}`; + } + } + return { ...result, install }; + } catch (err) { + if (status) status.textContent = `Lokaler FLUX-Test fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function installLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('install-local-image-provider-btn'); + const installModel = document.getElementById('local-flux-install-model')?.checked !== false; + const enableAutostart = document.getElementById('local-flux-enable-autostart')?.checked !== false; + const startNow = document.getElementById('local-flux-start-now')?.checked !== false; + const warning = installModel + ? 'ComfyUI und FLUX.1-schnell FP8 werden heruntergeladen. Das benötigt viel Speicherplatz und kann lange dauern. Installation starten?' + : 'ComfyUI wird installiert, das FLUX-Modell jedoch nicht. Installation starten?'; + if (!window.confirm(warning)) return null; + if (button) button.disabled = true; + if (status) status.textContent = 'Lokale FLUX-Installation wird gestartet ...'; + try { + const startedAt = Date.now(); + const result = await api('POST', '/api/local-image/install', { + install_model: installModel, + enable_autostart: enableAutostart, + start_now: startNow, + gpu: 'auto', + }); + if (status) status.textContent = result.message || 'Lokale FLUX-Installation wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 900)); + await pollLocalFluxInstall(startedAt); + return result; + } catch (err) { + if (status) status.textContent = `Lokale FLUX-Installation fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function updateLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('update-local-image-provider-btn'); + if (!window.confirm('Lokales ComfyUI jetzt aktualisieren? Bestehende Modelle bleiben erhalten.')) return null; + if (button) button.disabled = true; + try { + const startedAt = Date.now(); + const result = await api('POST', '/api/local-image/update', {}); + if (status) status.textContent = result.message || 'Lokales FLUX-Update wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 900)); + await pollLocalFluxInstall(startedAt); + return result; + } catch (err) { + if (status) status.textContent = `Update fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function startLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('start-local-image-provider-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); + if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird gestartet.'; + await waitForFluxEngineReady({ statusElement: status }); + return testLocalImageProvider(); + } catch (err) { + if (status) status.textContent = `Start fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function stopLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('stop-local-image-provider-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/stop', {}); + if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird beendet.'; + await new Promise(resolve => setTimeout(resolve, 1800)); + return testLocalImageProvider(); + } catch (err) { + if (status) status.textContent = `Stoppen fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function regenerateLocalImageToken() { + const field = document.getElementById('setting-ai-model-photo-local-token'); + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('regenerate-local-image-token-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/regenerate-token', {}); + if (field) field.value = result.token || ''; + if (status) status.textContent = 'Neuer lokaler Zugriffstoken erzeugt. Bitte Einstellungen speichern.'; + return result; + } catch (err) { + if (status) status.textContent = `Token konnte nicht erneuert werden: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +function updateAiPhotoProviderSettingsUi() { + const provider = document.getElementById('setting-ai-model-photo-provider')?.value || 'auto'; + const openrouterSelect = document.getElementById('setting-ai-model-photo-openrouter-model'); + const refreshBtn = document.getElementById('refresh-openrouter-models-btn'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only'); + const openAiField = document.getElementById('setting-ai-model-photo-openai-model'); + const localUrlField = document.getElementById('setting-ai-model-photo-local-url'); + const localTokenField = document.getElementById('setting-ai-model-photo-local-token'); + const localWorkflowField = document.getElementById('setting-ai-model-photo-local-workflow-path'); + const localInstallField = document.getElementById('setting-ai-model-photo-local-install-path'); + const localEnabled = document.getElementById('setting-ai-model-photo-local-enabled'); + const localRequireToken = document.getElementById('setting-ai-model-photo-local-require-token'); + const testBtn = document.getElementById('test-local-image-provider-btn'); + const installBtn = document.getElementById('install-local-image-provider-btn'); + const updateBtn = document.getElementById('update-local-image-provider-btn'); + const startBtn = document.getElementById('start-local-image-provider-btn'); + const stopBtn = document.getElementById('stop-local-image-provider-btn'); + const regenBtn = document.getElementById('regenerate-local-image-token-btn'); + const useLocal = provider === 'auto' || provider === 'local'; + const useOpenRouter = provider !== 'openai' && provider !== 'local'; + const useOpenAi = provider !== 'openrouter' && provider !== 'local'; + if (openrouterSelect) openrouterSelect.disabled = !useOpenRouter; + if (refreshBtn) refreshBtn.disabled = !useOpenRouter; + if (freeOnly) freeOnly.disabled = !useOpenRouter; + if (openAiField) openAiField.disabled = !useOpenAi; + if (localUrlField) localUrlField.disabled = !useLocal; + if (localTokenField) localTokenField.disabled = !useLocal; + if (localWorkflowField) localWorkflowField.disabled = !useLocal; + if (localInstallField) localInstallField.disabled = !useLocal; + if (localEnabled) localEnabled.disabled = false; + if (localRequireToken) localRequireToken.disabled = !useLocal; + if (testBtn) testBtn.disabled = false; + if (installBtn) installBtn.disabled = false; + if (updateBtn) updateBtn.disabled = false; + // Start/Stop werden ausschließlich vom realen Runtime-Status gesteuert. + if (regenBtn) regenBtn.disabled = false; +} + +function renderOpenRouterModelPreview(models = [], selectedId = '', freeOnly = true) { + const wrap = document.getElementById('openrouter-models-preview'); + const status = document.getElementById('openrouter-models-status'); + const select = document.getElementById('setting-ai-model-photo-openrouter-model'); + if (!wrap || !status || !select) return; + + const normalized = Array.isArray(models) ? models : []; + const options = ['']; + normalized.forEach(model => { + const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`; + options.push(``); + }); + select.innerHTML = options.join(''); + if (selectedId) select.value = selectedId; + if (!select.value) select.value = ''; + + if (!normalized.length) { + wrap.classList.add('hidden'); + wrap.innerHTML = ''; + status.textContent = freeOnly + ? 'Keine kostenlosen OpenRouter-Bildmodelle gefunden. In Auto nutzt Vendoo dann OpenAI als Fallback.' + : 'Keine OpenRouter-Bildmodelle gefunden.'; + return; + } + + const top = normalized.slice(0, 4); + wrap.classList.remove('hidden'); + wrap.innerHTML = top.map(model => ` +
                          +
                          +
                          + ${escapeHtml(model.name || model.id || 'OpenRouter Modell')} + ${escapeHtml(model.id || '')} +
                          +
                          + ${model.is_free ? 'Free' : 'Paid'} + Image +
                          +
                          +

                          ${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}

                          +
                          + `).join(''); + status.textContent = `${normalized.length} OpenRouter-Bildmodell(e) verfügbar${freeOnly ? ' (Free gefiltert)' : ''}.`; +} + +async function loadOpenRouterImageModels(selectedId = '') { + const status = document.getElementById('openrouter-models-status'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only')?.checked !== false; + if (status) status.textContent = 'OpenRouter-Bildmodelle werden geladen ...'; + try { + const result = await api('GET', `/api/ai-model-photos/providers/openrouter/models?free_only=${freeOnly ? '1' : '0'}`); + state.openRouterImageModels = result.models || []; + renderOpenRouterModelPreview(state.openRouterImageModels, selectedId, !!result.free_only); + if (freeOnly && state.openRouterImageModels.length === 0) { + try { + const diagnostics = await api('GET', '/api/ai-model-photos/providers/openrouter/diagnostics'); + if (status) { + status.textContent = diagnostics.free_image_model_count > 0 + ? `${diagnostics.free_image_model_count} Free-Bildmodell(e) erkannt, aber aktuell nicht nutzbar.` + : `Aktuell 0 kostenlose Bildmodelle bei OpenRouter; ${diagnostics.image_model_count || 0} kostenpflichtige Bildmodelle erkannt. Auto nutzt OpenAI als Fallback, sofern ein OpenAI-Key hinterlegt ist.`; + } + } catch {} + } + } catch (err) { + state.openRouterImageModels = []; + renderOpenRouterModelPreview([], selectedId, freeOnly); + if (status) status.textContent = `OpenRouter-Modelle konnten nicht geladen werden: ${err.message}`; + } +} + +function populateSettingsForm(settings, platforms, providers) { + const ps = document.getElementById('setting-default-platform'); + const fs = document.getElementById('filter-platform'); + const as = document.getElementById('setting-default-ai'); + + ps.innerHTML = ''; fs.innerHTML = ''; + for (const p of platforms) { + ps.innerHTML += ``; + fs.innerHTML += ``; + } + as.innerHTML = providers.map(p => ``).join(''); + + ps.value = settings.default_platform || 'vinted'; + as.value = settings.default_ai || 'claude'; + document.getElementById('setting-default-language').value = settings.default_language || 'de'; + document.getElementById('setting-seller-notes').value = settings.seller_notes || ''; + document.getElementById('setting-anthropic-key').placeholder = settings.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = settings.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = settings.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = settings.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ebay-client-id').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + document.getElementById('setting-ebay-sandbox').checked = !!settings.ebay_sandbox; + document.getElementById('setting-ollama-url').value = settings.ollama_url || 'http://localhost:11434'; + document.getElementById('setting-public-base-url').value = settings.public_base_url || ''; + detectPublicBaseUrl({ apply: !settings.public_base_url }); + document.getElementById('setting-ai-model-photo-provider').value = settings.ai_model_photo_provider || 'auto'; + const savedPhotoMode = ['ghost_mannequin', 'flatlay'].includes(settings.ai_model_photo_default_mode) ? settings.ai_model_photo_default_mode : 'flatlay'; + document.getElementById('setting-ai-model-photo-mode').value = savedPhotoMode; + document.getElementById('setting-ai-model-photo-variants').value = String(settings.ai_model_photo_default_variants || '1'); + document.getElementById('setting-ai-model-photo-preset').value = settings.ai_model_photo_default_preset || 'mixed'; + document.getElementById('setting-ai-model-photo-enabled').checked = String(settings.ai_model_photos_enabled || '1') === '1'; + document.getElementById('setting-ai-model-photo-free-only').checked = String(settings.ai_model_photo_openrouter_free_only || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-underwear').checked = String(settings.ai_model_photo_block_underwear || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-kids').checked = String(settings.ai_model_photo_block_kids || '1') === '1'; + document.getElementById('setting-ai-model-photo-preserve-logos').checked = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + getAiPromptDraft('generator').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + getAiPromptDraft('detail').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + document.getElementById('setting-ai-model-photo-openai-model').value = settings.ai_model_photo_openai_model || 'gpt-image-1'; + document.getElementById('setting-ai-model-photo-local-url').value = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + document.getElementById('setting-ai-model-photo-local-token').placeholder = settings.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)'; + document.getElementById('setting-ai-model-photo-local-workflow-path').value = settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + document.getElementById('setting-ai-model-photo-local-install-path').value = settings.ai_model_photo_local_install_path || 'local-ai/comfyui'; + document.getElementById('setting-ai-model-photo-local-enabled').checked = String(settings.ai_model_photo_local_enabled || '1') === '1'; + document.getElementById('setting-ai-model-photo-local-require-token').checked = String(settings.ai_model_photo_local_require_token || '1') === '1'; + document.getElementById('setting-ai-model-photo-use-source-reference').checked = String(settings.ai_model_photo_use_source_reference || '1') === '1'; + document.getElementById('setting-ai-model-photo-reference-strength').value = String(settings.ai_model_photo_reference_strength || '70'); + document.getElementById('setting-ai-model-photo-local-resolution').value = String(settings.ai_model_photo_local_resolution || '768'); + document.getElementById('setting-flux-runtime-profile').value = settings.flux_runtime_profile || 'auto'; + document.getElementById('setting-flux-default-profile').value = settings.flux_default_profile || 'balanced'; + document.getElementById('setting-flux-default-style').value = settings.flux_default_style || 'photorealistic'; + document.getElementById('setting-flux-default-format').value = settings.flux_default_format || '768x768'; + document.getElementById('setting-flux-default-variants').value = settings.flux_default_variants || '1'; + document.getElementById('setting-flux-max-parallel-jobs').value = settings.flux_max_parallel_jobs || '1'; + document.getElementById('setting-flux-prompt-assistant').checked = String(settings.flux_prompt_assistant || '1') === '1'; + document.getElementById('setting-flux-seed-lock').checked = String(settings.flux_seed_lock || '0') === '1'; + const studioStyle = document.getElementById('flux-studio-style'); if (studioStyle) studioStyle.value = settings.flux_default_style || 'photorealistic'; + const studioFormat = document.getElementById('flux-studio-format'); if (studioFormat) studioFormat.value = settings.flux_default_format || '768x768'; + const studioLock = document.getElementById('flux-studio-seed-lock'); if (studioLock) studioLock.checked = String(settings.flux_seed_lock || '0') === '1'; + applyFluxProfile(settings.flux_default_profile || 'balanced', { persist: false }); + setFluxVariantCount(settings.flux_default_variants || '1', { persist: false }); + updateReferenceStrengthUi(); + const aiStatusText = document.getElementById('settings-ai-status-text'); + const aiStatusChip = document.getElementById('settings-ai-status-chip'); + if (aiStatusText) aiStatusText.textContent = String(settings.ai_model_photos_enabled || '1') === '1' ? 'Aktiv' : 'Deaktiviert'; + if (aiStatusChip) aiStatusChip.className = `settings-status-chip ${String(settings.ai_model_photos_enabled || '1') === '1' ? 'is-running' : 'is-disabled'}`; + updateAiPhotoProviderSettingsUi(); + loadOpenRouterImageModels(settings.ai_model_photo_openrouter_model || ''); + testLocalImageProvider(); + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + const etsyBadge = document.getElementById('etsy-status-badge'); + if (settings.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + if (settings.etsy_connected) { + etsyBadge.textContent = 'Verbunden'; + etsyBadge.className = 'etsy-status connected'; + } else { + etsyBadge.textContent = 'Nicht verbunden'; + etsyBadge.className = 'etsy-status disconnected'; + } + } else { + etsyWrap.classList.add('hidden'); + } + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + const ebayBadge = document.getElementById('ebay-status-badge'); + if (settings.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + if (settings.ebay_connected) { + ebayBadge.textContent = settings.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebayBadge.className = 'etsy-status connected'; + } else { + ebayBadge.textContent = 'Nicht verbunden'; + ebayBadge.className = 'etsy-status disconnected'; + } + } else { + ebayWrap.classList.add('hidden'); + } +} + +function setupSettings() { + setupSettingsNavigation(); + updateReferenceStrengthUi(); + watchLocalFluxInstallStatus(); + document.getElementById('detect-public-base-url-btn')?.addEventListener('click', () => detectPublicBaseUrl({ apply: true })); + document.getElementById('setting-ai-model-photo-provider')?.addEventListener('change', updateAiPhotoProviderSettingsUi); + document.getElementById('setting-ai-model-photo-free-only')?.addEventListener('change', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + document.getElementById('refresh-openrouter-models-btn')?.addEventListener('click', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + document.getElementById('test-local-image-provider-btn')?.addEventListener('click', testLocalImageProvider); + document.getElementById('install-local-image-provider-btn')?.addEventListener('click', installLocalImageProvider); + document.getElementById('update-local-image-provider-btn')?.addEventListener('click', updateLocalImageProvider); + document.getElementById('start-local-image-provider-btn')?.addEventListener('click', startLocalImageProvider); + document.getElementById('stop-local-image-provider-btn')?.addEventListener('click', stopLocalImageProvider); + document.getElementById('regenerate-local-image-token-btn')?.addEventListener('click', regenerateLocalImageToken); + document.getElementById('uninstall-local-image-provider-btn')?.addEventListener('click', uninstallLocalImageProvider); + document.getElementById('setting-ai-model-photo-local-enabled')?.addEventListener('change', (event) => setLocalFluxEnabled(event.target.checked)); + document.getElementById('setting-ai-model-photo-reference-strength')?.addEventListener('input', updateReferenceStrengthUi); + document.getElementById('setting-ai-model-photo-use-source-reference')?.addEventListener('change', updateReferenceStrengthUi); + document.getElementById('setting-ai-model-photo-enabled')?.addEventListener('change', (event) => { + const text = document.getElementById('settings-ai-status-text'); + const chip = document.getElementById('settings-ai-status-chip'); + if (text) text.textContent = event.target.checked ? 'Aktiv' : 'Deaktiviert'; + if (chip) chip.className = `settings-status-chip ${event.target.checked ? 'is-running' : 'is-disabled'}`; + }); + document.querySelectorAll('#settings input, #settings select, #settings textarea').forEach(field => { + if (field.closest('#theme-manager-root')) return; + field.addEventListener('change', () => { + const title = document.getElementById('settings-save-title'); + const copy = document.getElementById('settings-save-copy'); + if (title) title.textContent = 'Ungespeicherte Änderungen'; + if (copy) copy.textContent = 'Mit „Einstellungen speichern“ übernehmen.'; + }); + }); + + document.getElementById('save-settings-btn').addEventListener('click', async () => { + try { + const settings = { + default_platform: document.getElementById('setting-default-platform').value, + default_ai: document.getElementById('setting-default-ai').value, + default_language: document.getElementById('setting-default-language').value, + seller_notes: document.getElementById('setting-seller-notes').value, + ollama_url: document.getElementById('setting-ollama-url').value, + public_base_url: document.getElementById('setting-public-base-url').value.trim(), + ai_model_photos_enabled: document.getElementById('setting-ai-model-photo-enabled').checked ? '1' : '0', + ai_model_photo_provider: document.getElementById('setting-ai-model-photo-provider').value, + ai_model_photo_default_mode: document.getElementById('setting-ai-model-photo-mode').value, + ai_model_photo_default_variants: document.getElementById('setting-ai-model-photo-variants').value, + ai_model_photo_default_preset: document.getElementById('setting-ai-model-photo-preset').value, + ai_model_photo_openrouter_free_only: document.getElementById('setting-ai-model-photo-free-only').checked ? '1' : '0', + ai_model_photo_block_underwear: document.getElementById('setting-ai-model-photo-block-underwear').checked ? '1' : '0', + ai_model_photo_block_kids: document.getElementById('setting-ai-model-photo-block-kids').checked ? '1' : '0', + ai_model_photo_preserve_logos: document.getElementById('setting-ai-model-photo-preserve-logos').checked ? '1' : '0', + ai_model_photo_openrouter_model: document.getElementById('setting-ai-model-photo-openrouter-model').value, + ai_model_photo_openai_model: document.getElementById('setting-ai-model-photo-openai-model').value.trim() || 'gpt-image-1', + ai_model_photo_local_enabled: document.getElementById('setting-ai-model-photo-local-enabled').checked ? '1' : '0', + ai_model_photo_local_url: document.getElementById('setting-ai-model-photo-local-url').value.trim() || 'http://127.0.0.1:8188', + ai_model_photo_local_workflow_path: document.getElementById('setting-ai-model-photo-local-workflow-path').value.trim() || 'local-ai/workflows/vendoo_flux_schnell_api.json', + ai_model_photo_local_install_path: document.getElementById('setting-ai-model-photo-local-install-path').value.trim() || 'local-ai/comfyui', + ai_model_photo_local_require_token: document.getElementById('setting-ai-model-photo-local-require-token').checked ? '1' : '0', + ai_model_photo_use_source_reference: document.getElementById('setting-ai-model-photo-use-source-reference').checked ? '1' : '0', + ai_model_photo_reference_strength: document.getElementById('setting-ai-model-photo-reference-strength').value || '70', + ai_model_photo_local_resolution: document.getElementById('setting-ai-model-photo-local-resolution').value || '768', + flux_runtime_profile: document.getElementById('setting-flux-runtime-profile').value || 'auto', + flux_default_profile: document.getElementById('setting-flux-default-profile').value || 'balanced', + flux_prompt_assistant: document.getElementById('setting-flux-prompt-assistant').checked ? '1' : '0', + flux_default_style: document.getElementById('setting-flux-default-style').value || 'photorealistic', + flux_default_format: document.getElementById('setting-flux-default-format').value || '768x768', + flux_default_variants: document.getElementById('setting-flux-default-variants').value || '1', + flux_seed_lock: document.getElementById('setting-flux-seed-lock').checked ? '1' : '0', + flux_max_parallel_jobs: document.getElementById('setting-flux-max-parallel-jobs').value || '1', + }; + const ak = document.getElementById('setting-anthropic-key').value; + const ok = document.getElementById('setting-openai-key').value; + const ork = document.getElementById('setting-openrouter-key').value; + const ek = document.getElementById('setting-etsy-key').value; + const localToken = document.getElementById('setting-ai-model-photo-local-token').value.trim(); + const ebayId = document.getElementById('setting-ebay-client-id').value; + const ebaySec = document.getElementById('setting-ebay-client-secret').value; + const ebayRu = document.getElementById('setting-ebay-ru-name').value; + const ebaySandbox = document.getElementById('setting-ebay-sandbox').checked; + if (ak) settings.anthropic_key = ak; + if (ok) settings.openai_key = ok; + if (ork) settings.openrouter_key = ork; + if (ek) settings.etsy_key = ek; + if (localToken) settings.ai_model_photo_local_token = localToken; + if (ebayId) settings.ebay_client_id = ebayId; + if (ebaySec) settings.ebay_client_secret = ebaySec; + if (ebayRu) settings.ebay_ru_name = ebayRu; + settings.ebay_sandbox = ebaySandbox ? 'true' : 'false'; + + const saved = await api('PUT', '/api/settings', settings); + state.platform = settings.default_platform; + state.aiProvider = settings.default_ai; + state.language = settings.default_language; + renderPlatformButtons(); + renderProviderButtons(); + document.getElementById('language-select').value = state.language; + + document.getElementById('setting-anthropic-key').value = ''; + document.getElementById('setting-openai-key').value = ''; + document.getElementById('setting-openrouter-key').value = ''; + document.getElementById('setting-etsy-key').value = ''; + document.getElementById('setting-ai-model-photo-local-token').value = ''; + document.getElementById('setting-ebay-client-id').value = ''; + document.getElementById('setting-ebay-client-secret').value = ''; + document.getElementById('setting-anthropic-key').placeholder = saved.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = saved.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = saved.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = saved.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ai-model-photo-local-token').placeholder = saved.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)'; + document.getElementById('setting-ebay-client-id').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + if (saved.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + const badge = document.getElementById('etsy-status-badge'); + if (saved.etsy_connected) { badge.textContent = 'Verbunden'; badge.className = 'etsy-status connected'; } + else { badge.textContent = 'Nicht verbunden'; badge.className = 'etsy-status disconnected'; } + } + + updateAiPhotoProviderSettingsUi(); + if (saved.public_base_url !== undefined) document.getElementById('setting-public-base-url').value = saved.public_base_url || ''; + detectPublicBaseUrl({ apply: false }); + loadOpenRouterImageModels(saved.ai_model_photo_openrouter_model || document.getElementById('setting-ai-model-photo-openrouter-model').value || ''); + testLocalImageProvider(); + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + if (saved.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + const ebBadge = document.getElementById('ebay-status-badge'); + if (saved.ebay_connected) { + ebBadge.textContent = saved.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebBadge.className = 'etsy-status connected'; + } else { + ebBadge.textContent = 'Nicht verbunden'; + ebBadge.className = 'etsy-status disconnected'; + } + } + const saveTitle = document.getElementById('settings-save-title'); + const saveCopy = document.getElementById('settings-save-copy'); + if (saveTitle) saveTitle.textContent = 'Einstellungen gespeichert'; + if (saveCopy) saveCopy.textContent = `Zuletzt gespeichert: ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + toast('Einstellungen gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('etsy-connect-btn').addEventListener('click', () => { + window.open('/auth/etsy', 'etsy-auth', 'width=600,height=700'); + }); + document.getElementById('ebay-connect-btn').addEventListener('click', () => { + window.open('/auth/ebay', 'ebay-auth', 'width=600,height=700'); + }); + window.addEventListener('message', async (e) => { + if (e.data === 'etsy-connected') { + const badge = document.getElementById('etsy-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('Etsy erfolgreich verbunden!'); + } + if (e.data === 'ebay-connected') { + const badge = document.getElementById('ebay-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('eBay erfolgreich verbunden!'); + } + }); + + document.querySelectorAll('.toggle-visibility').forEach(btn => { + btn.addEventListener('click', () => { + const inp = btn.previousElementSibling; + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + btn.textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + }); +} + +// --- Category Picker --- +const catCache = {}; + +async function loadCategories(platformId) { + if (catCache[platformId]) return catCache[platformId]; + try { + const data = await api('GET', `/api/categories/${platformId}`); + catCache[platformId] = data; + return data; + } catch { return { tree: [], flat: [] }; } +} + +function createCategoryPicker(container, platformId, initialValue) { + const wrap = typeof container === 'string' ? document.getElementById(container) : container; + if (!wrap) return { getValue: () => null, setValue: () => {} }; + + let selected = null; + let dropdownOpen = false; + + wrap.innerHTML = ` +
                          + +
                          + +
                          `; + + const input = wrap.querySelector('.cat-search'); + const dropdown = wrap.querySelector('.cat-dropdown'); + const selectedDisplay = wrap.querySelector('.cat-selected-display'); + + async function doSearch(q) { + const data = await loadCategories(platformId); + if (!q || q.length < 2) { + renderTree(data.tree); + return; + } + try { + const results = await api('GET', `/api/categories/${platformId}/match?q=${encodeURIComponent(q)}`); + renderResults(results); + } catch { renderResults([]); } + } + + function renderResults(results) { + if (!results.length) { + dropdown.innerHTML = '
                          Keine Kategorien gefunden
                          '; + openDropdown(); + return; + } + dropdown.innerHTML = results.map(r => ` +
                          +
                          ${escapeHtml(r.name)}
                          +
                          ${escapeHtml(r.pathStr)}
                          +
                          `).join(''); + dropdown.querySelectorAll('.cat-result-item').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function renderTree(tree) { + if (!tree || !tree.length) { + dropdown.innerHTML = '
                          Keine Kategorien verfügbar
                          '; + openDropdown(); + return; + } + dropdown.innerHTML = tree.map(group => { + const children = (group.children || []).map(sub => { + if (sub.children) { + const leaves = sub.children.map(leaf => + `
                          ${escapeHtml(leaf.name)}
                          ` + ).join(''); + return `
                          +
                          ${escapeHtml(sub.name)}
                          +
                          ${leaves}
                          +
                          `; + } + return `
                          ${escapeHtml(sub.name)}
                          `; + }).join(''); + return `
                          +
                          ${escapeHtml(group.name)}
                          + +
                          `; + }).join(''); + + dropdown.querySelectorAll('.cat-group-hd').forEach(hd => { + hd.addEventListener('click', () => { + const body = hd.nextElementSibling; + const expanded = hd.dataset.expanded === 'true'; + hd.dataset.expanded = expanded ? 'false' : 'true'; + body.style.display = expanded ? 'none' : 'block'; + hd.classList.toggle('expanded', !expanded); + }); + }); + + dropdown.querySelectorAll('.cat-leaf').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function selectCategory(path, name) { + selected = path; + input.value = ''; + input.style.display = 'none'; + selectedDisplay.style.display = 'flex'; + selectedDisplay.innerHTML = ` + ${escapeHtml(path)} + `; + selectedDisplay.querySelector('.cat-sel-clear').addEventListener('click', clearSelection); + closeDropdown(); + } + + function clearSelection() { + selected = null; + input.style.display = ''; + selectedDisplay.style.display = 'none'; + input.value = ''; + input.focus(); + } + + function openDropdown() { dropdown.style.display = 'block'; dropdownOpen = true; } + function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; } + + input.addEventListener('focus', () => doSearch(input.value)); + input.addEventListener('input', debounce(() => doSearch(input.value), 200)); + + document.addEventListener('click', e => { + if (dropdownOpen && !wrap.contains(e.target)) closeDropdown(); + }); + + if (initialValue) { + selectCategory(initialValue, initialValue.split(' > ').pop()); + } + + return { + getValue: () => selected, + setValue: (val) => { + if (val) selectCategory(val, val.split(' > ').pop()); + else clearSelection(); + }, + }; +} + +let metaCategoryPicker = null; + +function addQuillTooltips() { + const tooltips = { + 'ql-bold': 'Fett', 'ql-italic': 'Kursiv', 'ql-underline': 'Unterstrichen', + 'ql-strike': 'Durchgestrichen', 'ql-blockquote': 'Zitat', 'ql-link': 'Link einfügen', + 'ql-clean': 'Formatierung entfernen', 'ql-list[value="ordered"]': 'Nummerierte Liste', + 'ql-list[value="bullet"]': 'Aufzählung', 'ql-color': 'Textfarbe', + 'ql-background': 'Hintergrundfarbe', 'ql-align': 'Ausrichtung', 'ql-header': 'Überschrift', + }; + for (const [sel, tip] of Object.entries(tooltips)) { + document.querySelectorAll(`.ql-${sel}, .${sel}, button.${sel.split('[')[0]}`).forEach(el => { el.title = tip; }); + } + document.querySelectorAll('.ql-bold').forEach(el => { el.title = 'Fett'; }); + document.querySelectorAll('.ql-italic').forEach(el => { el.title = 'Kursiv'; }); + document.querySelectorAll('.ql-underline').forEach(el => { el.title = 'Unterstrichen'; }); + document.querySelectorAll('.ql-strike').forEach(el => { el.title = 'Durchgestrichen'; }); + document.querySelectorAll('.ql-blockquote').forEach(el => { el.title = 'Zitat'; }); + document.querySelectorAll('.ql-link').forEach(el => { el.title = 'Link einfügen'; }); + document.querySelectorAll('.ql-clean').forEach(el => { el.title = 'Formatierung entfernen'; }); + document.querySelectorAll('.ql-list[value="ordered"]').forEach(el => { el.title = 'Nummerierte Liste'; }); + document.querySelectorAll('.ql-list[value="bullet"]').forEach(el => { el.title = 'Aufzählung'; }); + document.querySelectorAll('.ql-color .ql-picker-label').forEach(el => { el.title = 'Textfarbe'; }); + document.querySelectorAll('.ql-background .ql-picker-label').forEach(el => { el.title = 'Hintergrundfarbe'; }); + document.querySelectorAll('.ql-align .ql-picker-label').forEach(el => { el.title = 'Ausrichtung'; }); + document.querySelectorAll('.ql-header .ql-picker-label').forEach(el => { el.title = 'Überschrift'; }); +} + +function generateQRDataUrl(text, size) { + if (typeof QRious === 'undefined') return null; + try { + const qr = new QRious({ value: text, size: size || 120, level: 'M' }); + return qr.toDataURL(); + } catch { return null; } +} + +let printStudioState = { + mode: 'labels', + listings: [], + listing: null, + copies: 1, + format: '90x50', + showPrice: true, +}; + +function ensurePrintStudio() { + let studio = document.getElementById('print-studio'); + if (studio) return studio; + + studio = document.createElement('div'); + studio.id = 'print-studio'; + studio.className = 'print-studio hidden'; + studio.innerHTML = ` + + + `; + document.body.appendChild(studio); + + studio.querySelectorAll('[data-print-close]').forEach(button => { + button.addEventListener('click', closePrintStudio); + }); + studio.querySelector('#print-format-select').addEventListener('change', event => { + printStudioState.format = event.target.value; + renderPrintStudio(); + }); + studio.querySelector('#print-copies-input').addEventListener('input', event => { + printStudioState.copies = Math.max(1, Math.min(20, Number(event.target.value) || 1)); + renderPrintStudio(); + }); + studio.querySelector('#print-show-price').addEventListener('change', event => { + printStudioState.showPrice = event.target.checked; + renderPrintStudio(); + }); + studio.querySelector('#print-now-btn').addEventListener('click', () => { + document.body.classList.add('print-studio-active'); + window.print(); + }); + window.addEventListener('afterprint', () => document.body.classList.remove('print-studio-active')); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !studio.classList.contains('hidden')) closePrintStudio(); + }); + return studio; +} + +function closePrintStudio() { + const studio = document.getElementById('print-studio'); + if (!studio) return; + studio.classList.add('hidden'); + document.body.classList.remove('print-studio-open', 'print-studio-active'); +} + +function openPrintStudio(mode, payload) { + printStudioState = { + mode, + listings: payload.listings || [], + listing: payload.listing || null, + copies: 1, + format: '90x50', + showPrice: true, + }; + const studio = ensurePrintStudio(); + studio.classList.remove('hidden'); + document.body.classList.add('print-studio-open'); + studio.querySelector('#print-format-select').value = printStudioState.format; + studio.querySelector('#print-copies-input').value = '1'; + studio.querySelector('#print-show-price').checked = true; + renderPrintStudio(); +} + +function printLabels(listings) { + if (!Array.isArray(listings) || !listings.length) return; + openPrintStudio('labels', { listings }); +} + +function printDatasheet(listing) { + if (!listing) return; + openPrintStudio('datasheet', { listing }); +} + +function getPrintPlatformName(platform) { + return state.platforms.find(item => item.id === platform)?.name || platformLabel(platform); +} + +function buildPrintLabel(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const title = (listing.title || 'Ohne Titel').substring(0, 60); + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 170); + const price = listing.price ? formatCurrency(listing.price) : ''; + const platform = getPrintPlatformName(listing.platform); + return ` + + `; +} + +function buildPrintDatasheet(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 190); + const photos = (listing.photos || []).slice(0, 4); + const dateValue = listing.created_at ? new Date(`${listing.created_at}Z`) : new Date(); + const date = Number.isNaN(dateValue.getTime()) ? '' : dateValue.toLocaleDateString('de-DE'); + const meta = [ + ['Marke', listing.brand], + ['Kategorie', listing.category], + ['Größe', listing.size], + ['Farbe', listing.color], + ['Zustand', listing.condition], + ['Preis', listing.price ? formatCurrency(listing.price) : ''], + ['Plattform', getPrintPlatformName(listing.platform)], + ['Lagerort', listing.storage_location], + ['Erstellt', date], + ].filter(([, value]) => value); + + return ` + + `; +} + +function chunkPrintItems(items, size) { + const chunks = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +function renderPrintStudio() { + const studio = ensurePrintStudio(); + const pages = studio.querySelector('#print-preview-pages'); + const summary = studio.querySelector('#print-mode-summary'); + const count = studio.querySelector('#print-preview-count'); + const formatField = studio.querySelector('#print-format-field'); + const copiesField = studio.querySelector('#print-copies-field'); + const priceField = studio.querySelector('#print-price-field'); + + const labelsMode = printStudioState.mode === 'labels'; + formatField.classList.toggle('hidden', !labelsMode); + copiesField.classList.toggle('hidden', !labelsMode); + priceField.classList.toggle('hidden', !labelsMode); + + if (!labelsMode) { + const listing = printStudioState.listing; + summary.innerHTML = `
                          Datenblatt${escapeHtml(listing?.sku || listing?.title || 'Artikel')}
                          `; + count.textContent = '1 Seite'; + pages.innerHTML = ``; + return; + } + + const expanded = []; + printStudioState.listings.forEach(listing => { + for (let copy = 0; copy < printStudioState.copies; copy += 1) expanded.push(listing); + }); + const compact = printStudioState.format === '62x29'; + const pageSize = compact ? 27 : 10; + const pageChunks = chunkPrintItems(expanded, pageSize); + summary.innerHTML = `
                          Etiketten${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten
                          `; + count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`; + pages.innerHTML = pageChunks.map(chunk => ` + + `).join(''); +} + +// --- Trash / Papierkorb --- +function setupTrash() { + document.getElementById('empty-trash-btn').addEventListener('click', async () => { + if (!confirm('Papierkorb endgültig leeren? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', '/api/trash'); + toast('Papierkorb geleert'); + loadTrash(); + updateTrashBadge(); + }); +} + +async function loadTrash() { + try { + const listings = await api('GET', '/api/trash'); + const c = document.getElementById('trash-list'); + if (!listings.length) { + c.innerHTML = '
                          Papierkorb ist leer
                          '; + return; + } + + c.innerHTML = listings.map(l => { + const photo = l.photos?.[0] ? `` : '
                          '; + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const deletedDate = l.deleted_at ? new Date(l.deleted_at + 'Z').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; + const priceStr = l.price ? `${l.price}€` : ''; + + return `
                          + ${photo} +
                          +
                          ${escapeHtml(l.title || 'Ohne Titel')}
                          +
                          ${pName} · ${priceStr} · Gelöscht: ${deletedDate}
                          +
                          +
                          + + +
                          +
                          `; + }).join(''); + + c.querySelectorAll('.restore-btn').forEach(btn => { + btn.addEventListener('click', async () => { + await api('POST', `/api/trash/${btn.dataset.id}/restore`); + toast('Listing wiederhergestellt'); + loadTrash(); + updateTrashBadge(); + }); + }); + + c.querySelectorAll('.perm-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Listing endgültig löschen? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', `/api/trash/${btn.dataset.id}`); + toast('Endgültig gelöscht'); + loadTrash(); + updateTrashBadge(); + }); + }); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function updateTrashBadge() { + try { + const data = await api('GET', '/api/trash/count'); + const badge = document.getElementById('trash-badge'); + if (data.count > 0) { + badge.textContent = data.count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } catch {} +} + +// --- Utils --- +function toast(msg) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2500); +} + +function debounce(fn, ms) { + let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; +} + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; +} + +// --- Dark Mode --- +function setupDarkMode() { + const saved = localStorage.getItem('vendoo-theme'); + if (window.VendooTheme) { + window.VendooTheme.setTheme(saved === 'light' || saved === 'dark' || saved === 'contrast' ? saved : 'system', { persist: false }); + } else if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.setAttribute('data-theme', 'dark'); + } + updateThemeIcon(); + + document.getElementById('theme-toggle').addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + const nextTheme = isDark ? 'light' : 'dark'; + if (window.VendooTheme) window.VendooTheme.setTheme(nextTheme); + else { + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem('vendoo-theme', nextTheme); + } + updateThemeIcon(); + }); +} + +function updateThemeIcon() { + const btn = document.getElementById('theme-toggle'); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + btn.title = isDark ? 'Light Mode' : 'Dark Mode'; + btn.innerHTML = isDark + ? '' + : ''; +} + +// --- Dashboard & Studio Flow --- +const STUDIO_STAGES = [ + { key: 'ready', label: 'Bereit', state: 'Prüfbereit', tone: 'success' }, + { key: 'scheduled', label: 'Geplant', state: 'Geplant', tone: 'warning' }, + { key: 'published', label: 'Veröffentlicht', state: 'Live', tone: 'success' }, + { key: 'sold', label: 'Verkauft', state: 'Verkauft', tone: 'success' }, +]; + +let studioWorkflowData = null; +let selectedStudioListingId = null; +let todayFilter = 'all'; +let dashboardAnalyticsData = null; +let dashboardResizeObserver = null; +let dashboardResizeTimer = null; +let dashboardLastChartWidth = 0; + +function formatCurrency(value) { + if (value === null || value === undefined || value === '') return ''; + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value) || 0); +} + +function platformLabel(platform) { + return ({ vinted: 'Vinted', 'ebay-ka': 'Kleinanzeigen', 'ebay-de': 'eBay', etsy: 'Etsy', klekt: 'KLEKT' })[platform] || platform || '—'; +} + +function photoUrl(photo) { + if (!photo) return ''; + return `/uploads/${String(photo).split('/').map(encodeURIComponent).join('/')}`; +} + +function relativeTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const diff = Math.max(0, Date.now() - date.getTime()); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'Gerade eben'; + if (minutes < 60) return `vor ${minutes} Min.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `vor ${hours} Std.`; + const days = Math.floor(hours / 24); + if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`; + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function activityGlyph(type) { + return ({ sold: '✓', published: '↗', failed: '!', scheduled: '◷', copied: '⧉', pending: '·', created: '+' })[type] || '·'; +} + +function setupAnalyticsDashboard() { + document.getElementById('dashboard-period')?.addEventListener('change', loadAnalyticsDashboard); + document.getElementById('dashboard-refresh')?.addEventListener('click', loadAnalyticsDashboard); + + const dashboardPage = document.getElementById('dashboard'); + const redrawDashboard = () => { + if (!dashboardAnalyticsData || !dashboardPage?.classList.contains('active')) return; + clearTimeout(dashboardResizeTimer); + dashboardResizeTimer = setTimeout(() => { + const width = document.getElementById('dashboard-line-chart')?.clientWidth || 0; + if (Math.abs(width - dashboardLastChartWidth) < 12) return; + dashboardLastChartWidth = width; + renderDashboardLineChart(dashboardAnalyticsData.timeline || []); + renderDashboardBars(dashboardAnalyticsData.platformRevenue || []); + renderDashboardCategories(dashboardAnalyticsData.categoryRevenue || []); + }, 90); + }; + if ('ResizeObserver' in window && dashboardPage) { + dashboardResizeObserver?.disconnect(); + dashboardResizeObserver = new ResizeObserver(redrawDashboard); + dashboardResizeObserver.observe(dashboardPage); + } else { + window.addEventListener('resize', redrawDashboard, { passive: true }); + } + + document.querySelectorAll('[data-dashboard-action]').forEach(button => button.addEventListener('click', async () => { + const action = button.dataset.dashboardAction; + if (action === 'new') return switchToTab('generator'); + if (action === 'qr') { + switchToTab('generator'); + setTimeout(() => document.getElementById('generator-mobile-upload')?.click(), 160); + return; + } + if (action === 'publish') return switchToTab('publish'); + if (action === 'template') return switchToTab('templates'); + if (action === 'photos') { + try { + const listings = await api('GET', '/api/listings'); + const photos = listings.flatMap(item => item.photos || []).slice(0, 60); + await downloadPublishPhotos(photos); + } catch (error) { toast(`Fotos konnten nicht geladen werden: ${error.message}`); } + } + })); +} + +function setupTodayFlow() { + document.querySelectorAll('[data-today-filter]').forEach(button => button.addEventListener('click', () => { + todayFilter = button.dataset.todayFilter || 'all'; + document.querySelectorAll('.today-filter-tabs [data-today-filter]').forEach(tab => tab.classList.toggle('active', tab.dataset.todayFilter === todayFilter)); + renderTodayReady(studioWorkflowData); + if (todayFilter === 'attention') document.querySelector('.attention-panel')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + })); + document.getElementById('today-sort')?.addEventListener('change', () => renderTodayReady(studioWorkflowData)); +} + +function dashboardSetText(id, value) { + const element = document.getElementById(id); + if (element) element.textContent = value; +} + +function renderSparkline(id, values, tone = 'brand') { + const svg = document.getElementById(id); + if (!svg) return; + const raw = (values || []).map(Number).filter(Number.isFinite); + const nums = raw.length > 1 ? raw : [raw[0] || 0, raw[0] || 0]; + const max = Math.max(...nums, 1); + const min = Math.min(...nums, 0); + const range = Math.max(1, max - min); + const points = nums.map((value, index) => `${(index / Math.max(1, nums.length - 1)) * 96 + 2},${24 - ((value - min) / range) * 18}`).join(' '); + const last = points.split(' ').at(-1)?.split(',') || ['98', '14']; + svg.setAttribute('viewBox', '0 0 100 28'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.innerHTML = ``; + svg.dataset.tone = tone; +} + +function renderDashboardLineChart(timeline) { + const container = document.getElementById('dashboard-line-chart'); + if (!container) return; + const values = timeline.map(item => Number(item.revenue) || 0); + const max = Math.max(...values, 1); + const measuredWidth = Math.round(container.getBoundingClientRect().width || container.clientWidth || 720); + const width = Math.max(320, measuredWidth); + const height = width < 520 ? 220 : 250; + const left = width < 520 ? 38 : 48; + const right = 14; + const top = 16; + const bottom = 34; + const chartW = width - left - right; + const chartH = height - top - bottom; + const points = values.map((value, index) => { + const x = left + (index / Math.max(1, values.length - 1)) * chartW; + const y = top + chartH - (value / max) * chartH; + return { x, y, value, item: timeline[index] }; + }); + const line = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); + const area = `${left},${top + chartH} ${line} ${left + chartW},${top + chartH}`; + const yGrid = [0, .25, .5, .75, 1].map(part => { + const y = top + chartH - part * chartH; + return `${Math.round(max * part).toLocaleString('de-DE')} €`; + }).join(''); + const step = Math.max(1, Math.floor(timeline.length / 5)); + const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1 + ? `${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}` : '').join(''); + container.innerHTML = `${yGrid}${labels}${points.map(point => `${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}`).join('')}`; + dashboardLastChartWidth = measuredWidth; +} + +function renderDashboardBars(items) { + const container = document.getElementById('dashboard-platform-chart'); + if (!container) return; + const data = items.length ? items.slice(0, 5) : [{ platform: 'Keine Verkäufe', revenue: 0 }]; + const max = Math.max(...data.map(item => Number(item.revenue) || 0), 1); + container.innerHTML = data.map(item => `
                          ${escapeHtml(formatCurrency(item.revenue))}
                          ${escapeHtml(platformLabel(item.platform))}
                          `).join(''); +} + +function renderDashboardCategories(items) { + const container = document.getElementById('dashboard-category-chart'); + if (!container) return; + const data = items.length ? items : [{ category: 'Keine Verkäufe', revenue: 1 }]; + const total = data.reduce((sum, item) => sum + (Number(item.revenue) || 0), 0) || 1; + let cursor = 0; + const colors = ['#49a66f', '#3b82dc', '#8054b3', '#e87334', '#e4ab2f', '#b9b6b0']; + const stops = data.map((item, index) => { + const start = cursor; + cursor += ((Number(item.revenue) || 0) / total) * 100; + return `${colors[index % colors.length]} ${start}% ${cursor}%`; + }).join(','); + container.innerHTML = `
                          ${escapeHtml(formatCurrency(total))}Gesamt
                          ${data.map((item, index) => `
                          ${escapeHtml(item.category)}${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%
                          `).join('')}
                          `; +} + +function renderDashboardFocus(focus) { + const container = document.getElementById('dashboard-focus'); + if (!container) return; + const items = [ + ['ready', focus.ready, 'Artikel bereit zum Veröffentlichen', 'history'], + ['price', focus.priceChecks, 'Preisaktualisierungen empfohlen', 'history'], + ['failed', focus.failedPublishes, 'fehlgeschlagene Publish-Versuche', 'publish'], + ['uploads', focus.mobileUploads, 'neue Handy-Foto-Uploads', 'generator'], + ['locations', focus.activeLocations, 'aktive Lagerorte', 'inventory'], + ]; + container.innerHTML = items.map(([tone, count, label, tab]) => ``).join(''); + container.querySelectorAll('[data-focus-tab]').forEach(button => button.addEventListener('click', () => switchToTab(button.dataset.focusTab))); +} + +function renderDashboardTopSellers(items) { + const body = document.getElementById('dashboard-top-sellers'); + if (!body) return; + body.innerHTML = items.length ? items.map(item => `
                          ${item.photo ? `` : ''}
                          ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))}
                          ${escapeHtml(item.sku || '')}${escapeHtml(platformLabel(item.platform))}${escapeHtml(formatCurrency(item.revenue))}${item.sales || 1}Verkauft`).join('') : 'Noch keine Verkäufe vorhanden.'; + body.querySelectorAll('[data-dashboard-listing]').forEach(row => row.addEventListener('click', () => showDetail(Number(row.dataset.dashboardListing)))); +} + +function renderDashboardFeed(id, items, isNotification = false) { + const container = document.getElementById(id); + if (!container) return; + if (!items?.length) { container.innerHTML = '
                          Keine Einträge
                          '; return; } + container.innerHTML = items.slice(0, 6).map(item => ``).join(''); + container.querySelectorAll('[data-feed-listing]').forEach(button => button.addEventListener('click', () => { const idValue = Number(button.dataset.feedListing); if (idValue) showDetail(idValue); })); +} + +async function loadAnalyticsDashboard() { + const period = Number(document.getElementById('dashboard-period')?.value || 30); + const refresh = document.getElementById('dashboard-refresh'); + refresh?.classList.add('is-loading'); + try { + const data = await api('GET', `/api/dashboard/analytics?days=${period}`); + dashboardAnalyticsData = data; + dashboardSetText('dash-revenue-today', formatCurrency(data.kpis.revenueToday)); + dashboardSetText('dash-sales-week', String(data.kpis.sales7Days)); + dashboardSetText('dash-active-listings', String(data.kpis.activeListings)); + dashboardSetText('dash-drafts', String(data.kpis.drafts)); + dashboardSetText('dash-inventory-value', formatCurrency(data.kpis.inventoryValue)); + dashboardSetText('dash-publish-rate', `${data.kpis.publishSuccessRate.toLocaleString('de-DE')}%`); + dashboardSetText('dash-period-revenue', formatCurrency(data.timeline.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + dashboardSetText('dash-period-label', `Letzte ${data.periodDays} Tage`); + dashboardSetText('dash-platform-total', formatCurrency(data.platformRevenue.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + const timelineValues = data.timeline.map(item => item.revenue); + renderSparkline('spark-revenue', timelineValues, 'green'); + renderSparkline('spark-sales', data.timeline.map(item => item.sales), 'blue'); + renderSparkline('spark-active', timelineValues.map((_, index) => Math.max(0, data.kpis.activeListings - data.timeline.length + index)), 'purple'); + renderSparkline('spark-drafts', timelineValues.map((_, index) => Math.max(0, data.kpis.drafts + Math.sin(index) * 2)), 'orange'); + renderSparkline('spark-inventory', timelineValues.map((value, index) => data.kpis.inventoryValue * .9 + index * (data.kpis.inventoryValue * .1 / Math.max(1, timelineValues.length))), 'green'); + renderSparkline('spark-publish', timelineValues.map((_, index) => Math.max(0, data.kpis.publishSuccessRate - 4 + index / 3)), 'blue'); + renderDashboardLineChart(data.timeline); + renderDashboardBars(data.platformRevenue); + renderDashboardCategories(data.categoryRevenue); + renderDashboardFocus(data.focus); + renderDashboardTopSellers(data.topSellers); + renderDashboardFeed('dashboard-activity', data.activity); + renderDashboardFeed('dashboard-notifications', data.notifications, true); + dashboardSetText('dashboard-quality-value', `${data.kpis.quality}%`); + dashboardSetText('dashboard-quality-label', data.kpis.quality >= 85 ? 'Sehr hoch' : data.kpis.quality >= 65 ? 'Gut, mit Potenzial' : 'Optimierung empfohlen'); + document.getElementById('dashboard-quality-ring')?.style.setProperty('--confidence', `${data.kpis.quality}%`); + dashboardSetText('dashboard-system-state', data.system.healthy ? 'Alle Systeme sind betriebsbereit' : 'Prüfung erforderlich'); + dashboardSetText('dashboard-system-copy', `${data.system.queuePending} Queue-Einträge ausstehend · ${data.system.scheduled} Veröffentlichungen geplant.`); + } catch (error) { + toast(`Dashboard konnte nicht geladen werden: ${error.message}`); + } finally { refresh?.classList.remove('is-loading'); } +} + +function getStudioItem(id) { + if (!studioWorkflowData?.stages) return null; + for (const stage of Object.values(studioWorkflowData.stages)) { + const found = stage.items?.find(item => item.id === id); + if (found) return found; + } + return null; +} + +function currentTodayItems(data) { + if (!data?.stages) return []; + const key = todayFilter === 'all' ? 'ready' : todayFilter; + if (key === 'attention') return [...(data.stages.captured?.items || []), ...(data.stages.ai_review?.items || [])]; + return data.stages[key]?.items || []; +} + +function renderTodaySummary(data) { + const set = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = String(value || 0); }; + set('today-count-ready', data.stages?.ready?.count); + set('today-count-scheduled', data.stages?.scheduled?.count); + set('today-count-published', data.stages?.published?.count); + set('today-count-sold', data.stages?.sold?.count); + set('today-count-attention', data.attentionCount); + set('today-ready-total', data.stages?.ready?.count); + set('today-scheduled-total', data.stages?.scheduled?.count); +} + +function renderTodayReady(data) { + const container = document.getElementById('today-ready-grid'); + if (!container || !data) return; + const labels = { all: 'Bereit für den nächsten Schritt', ready: 'Bereit für den nächsten Schritt', scheduled: 'Geplante Artikel', published: 'Veröffentlichte Artikel', sold: 'Verkaufte Artikel', attention: 'Artikel mit Prüfbedarf' }; + const heading = container.closest('.work-panel')?.querySelector('h3'); + if (heading) heading.textContent = labels[todayFilter] || labels.all; + let items = [...currentTodayItems(data)]; + const sort = document.getElementById('today-sort')?.value || 'priority'; + if (sort === 'newest') items.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt))); + if (sort === 'price') items.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)); + items = items.slice(0, 4); + if (!items.length) { container.innerHTML = '
                          Für diesen Filter sind keine Artikel vorhanden.
                          '; return; } + container.innerHTML = items.map(item => ``).join(''); + container.querySelectorAll('.today-product-media img').forEach(image => image.addEventListener('error', () => { + image.hidden = true; + const placeholder = document.createElement('span'); + placeholder.className = 'today-image-placeholder'; + placeholder.textContent = 'Bild nicht verfügbar'; + image.parentElement?.prepend(placeholder); + }, { once: true })); + container.querySelectorAll('[data-studio-listing]').forEach(card => card.addEventListener('click', () => selectStudioListing(Number(card.dataset.studioListing)))); +} + +function renderTodayScheduled(data) { + const body = document.getElementById('today-scheduled-list'); + if (!body) return; + const items = data.stages?.scheduled?.items?.slice(0, 4) || []; + body.innerHTML = items.length ? items.map(item => `${escapeHtml(platformLabel(item.platform))}${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}Geplant`).join('') : 'Keine geplanten Veröffentlichungen.'; + body.querySelectorAll('[data-scheduled-id]').forEach(button => button.addEventListener('click', () => selectStudioListing(Number(button.dataset.scheduledId)))); +} + +function renderStudioAttention(data) { + const list = document.getElementById('studio-attention'); + const count = document.getElementById('attention-count'); + if (count) count.textContent = String(data.attentionCount || 0); + if (!list) return; + if (!data.attention?.length) { list.innerHTML = '
                          Alles erledigt – aktuell ist keine Aktion nötig.
                          '; return; } + const grouped = new Map(); + for (const item of data.attention) { + const current = grouped.get(item.type) || { ...item, count: 0 }; + current.count += 1; + grouped.set(item.type, current); + } + list.innerHTML = [...grouped.values()].slice(0, 5).map(item => ``).join(''); + list.querySelectorAll('[data-attention-id]').forEach(button => button.addEventListener('click', () => { + const id = Number(button.dataset.attentionId); + if (button.dataset.attentionType === 'publish_failed') switchToTab('publish'); + else showDetail(id); + })); +} + +function renderStudioActivity(data) { + const recent = document.getElementById('studio-activity'); + const completed = document.getElementById('studio-completed'); + const render = (container, items) => { + if (!container) return; + container.innerHTML = items.length ? items.map(item => ``).join('') : '
                          Noch keine Aktivität
                          '; + container.querySelectorAll('[data-activity-listing]').forEach(button => button.addEventListener('click', () => { const id = Number(button.dataset.activityListing); if (id) selectStudioListing(id); })); + }; + const completeTypes = new Set(['published', 'sold']); + render(recent, data.activity.filter(item => !completeTypes.has(item.type)).slice(0, 4)); + render(completed, data.activity.filter(item => completeTypes.has(item.type)).slice(0, 4)); +} + +function selectStudioListing(id) { + const item = getStudioItem(id); + if (!item) return; + selectedStudioListingId = id; + document.querySelectorAll('[data-studio-listing]').forEach(card => card.classList.toggle('selected', Number(card.dataset.studioListing) === id)); + renderStudioInspector(item); +} + +function renderStudioInspector(item) { + const empty = document.getElementById('studio-inspector-empty'); + const content = document.getElementById('studio-inspector-content'); + if (!content || !empty) return; + empty.classList.add('hidden'); + content.classList.remove('hidden'); + const photos = item.photos || []; + const hero = photos[0] ? `${escapeHtml(item.title)}` : '
                          Kein Bild
                          '; + const confidence = Math.max(0, Math.min(100, item.completeness || 0)); + const low = Math.max(0, (Number(item.price) || 0) * .92); + const high = Math.max(low, (Number(item.price) || 0) * 1.08); + content.innerHTML = `

                          ${escapeHtml(item.title)}

                          SKU  ${escapeHtml(item.sku || '')}
                          ${item.status === 'sold' ? 'Verkauft' : 'Bereit'}
                          ${hero}▧ ${photos.length}
                          ${photos.length > 1 ? `
                          ${photos.slice(0, 5).map(photo => ``).join('')}${photos.length > 5 ? `+${photos.length - 5}` : ''}
                          ` : ''}
                          Preis${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}

                          AI-Konfidenz

                          ${confidence}%
                          ${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}

                          Wichtige Attribute

                          Marke
                          ${escapeHtml(item.brand || '—')}
                          Kategorie
                          ${escapeHtml(item.category || '—')}
                          Farbe
                          ${escapeHtml(item.color || '—')}
                          Größe
                          ${escapeHtml(item.size || '—')}
                          Zustand
                          ${escapeHtml(item.condition || '—')}
                          Lagerort
                          ${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}
                          ${item.price ? `

                          Preisempfehlung

                          ${escapeHtml(formatCurrency(low))} – ${escapeHtml(formatCurrency(high))}
                          ` : ''}
                          `; + content.querySelector('[data-inspector-action="review"]')?.addEventListener('click', () => showDetail(item.id)); + content.querySelector('[data-inspector-action="publish"]')?.addEventListener('click', () => { publishState.selectedId = item.id; switchToTab('publish'); }); + content.querySelector('[data-inspector-action="inventory"]')?.addEventListener('click', () => switchToTab('inventory')); +} + +async function loadToday() { + const ready = document.getElementById('today-ready-grid'); + if (ready) ready.innerHTML = '
                          Studio Flow wird geladen…
                          '; + try { + const data = await api('GET', '/api/dashboard/workflow?limit=12'); + studioWorkflowData = data; + renderTodaySummary(data); + renderTodayReady(data); + renderTodayScheduled(data); + renderStudioAttention(data); + renderStudioActivity(data); + const first = data.stages?.ready?.items?.[0] || data.stages?.scheduled?.items?.[0] || data.stages?.published?.items?.[0] || data.stages?.sold?.items?.[0]; + if (first && !selectedStudioListingId) selectStudioListing(first.id); + else if (selectedStudioListingId) { const selected = getStudioItem(selectedStudioListingId); if (selected) renderStudioInspector(selected); } + } catch (error) { + if (ready) ready.innerHTML = `
                          Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + + +// --- Advanced Image Editor --- +function createPhotoEditorOperations() { + return { + rotation: 0, + straighten: 0, + flip_h: false, + flip_v: false, + crop: null, + exposure: 0, + brightness: 0, + contrast: 0, + highlights: 0, + shadows: 0, + temperature: 0, + saturation: 0, + sharpness: 0, + remove_background: false, + background_sensitivity: 45, + watermark: { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 }, + resize: { width: 0, height: 0 }, + format: 'jpeg', + quality: 90, + }; +} + +let photoEditorState = { + context: 'generator', platform: '', index: -1, + currentPath: '', rootPath: '', sourceImage: null, rootImage: null, + operations: createPhotoEditorOperations(), history: [], historyIndex: -1, + versions: [], compare: false, cropping: false, cropRect: null, + cropStart: null, cropEnd: null, _dragOrigin: null, _dragging: false, +}; + +function cloneEditorOperations(value = photoEditorState.operations) { + return JSON.parse(JSON.stringify(value)); +} + +function getPhotoEditorPhotos() { + if (photoEditorState.context === 'flux') return state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []; + if (photoEditorState.context === 'media') return state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []; + return photoEditorState.context === 'detail' ? (detailEditorState?.photos || []) : state.photos; +} + +async function commitPhotoEditorResult(filename) { + if (!filename) return; + if (photoEditorState.context === 'flux') { + const current = state.fluxStudioCurrent || {}; + const item = await api('POST', '/api/flux-studio/images', { + image_path: filename, + prompt: current.prompt || 'Nachbearbeitete FLUX-Ausgabe', + width: current.width, + height: current.height, + seed: current.seed, + steps: current.steps, + style: current.style, + final_prompt: current.final_prompt, + profile: current.profile, + duration_ms: current.duration_ms, + edited_from: current.id || null, + favorite: current.favorite, + job_id: current.job_id, + variant_index: current.variant_index, + prompt_mode: current.prompt_mode, + parameters: current.parameters, + }); + renderFluxStudioPreview(item); + await loadFluxStudioHistory(); + return; + } + if (photoEditorState.context === 'media') { + state.mediaCurrent = { ...(state.mediaCurrent || {}), image_path: filename, public_url: photoUrl(filename), source: 'edited', sources: ['edited'] }; + await loadMediaLibrary(); + return; + } + const photos = getPhotoEditorPhotos(); + if (!photos[photoEditorState.index]) return; + photos[photoEditorState.index] = filename; + if (photoEditorState.context === 'detail') renderDetailPhotos(); + else renderPhotoPreview(); +} + +function getPhotoEditorPlatform() { + if (photoEditorState.context === 'flux') return 'ai-generated'; + if (photoEditorState.context === 'media') return state.mediaCurrent?.source || 'media-library'; + if (photoEditorState.context === 'detail') { + return document.getElementById('detail-platform')?.value || detailEditorState?.listing?.platform || 'allgemein'; + } + return state.platform || 'allgemein'; +} + +function setupPhotoEditor() { + document.getElementById('photo-editor-close').addEventListener('click', closePhotoEditor); + document.getElementById('pe-cancel').addEventListener('click', closePhotoEditor); + document.getElementById('photo-editor-overlay').addEventListener('click', event => { + if (event.target === event.currentTarget) closePhotoEditor(); + }); + + document.getElementById('pe-undo').addEventListener('click', () => stepPhotoEditorHistory(-1)); + document.getElementById('pe-redo').addEventListener('click', () => stepPhotoEditorHistory(1)); + document.getElementById('pe-compare').addEventListener('click', togglePhotoEditorCompare); + document.getElementById('pe-restore-original').addEventListener('click', restorePhotoEditorOriginal); + + const sliders = { + straighten: { digits: 1, suffix: '°' }, + exposure: { digits: 1, suffix: '' }, + brightness: { digits: 0, suffix: '' }, + contrast: { digits: 0, suffix: '' }, + highlights: { digits: 0, suffix: '' }, + shadows: { digits: 0, suffix: '' }, + temperature: { digits: 0, suffix: '' }, + saturation: { digits: 0, suffix: '' }, + sharpness: { digits: 0, suffix: '' }, + }; + for (const [property, format] of Object.entries(sliders)) { + const slider = document.getElementById(`pe-${property}`); + slider.addEventListener('input', () => { + photoEditorState.operations[property] = Number(slider.value); + updatePhotoEditorRangeValue(property, format.digits, format.suffix); + renderPhotoEditorCanvas(); + }); + slider.addEventListener('change', pushPhotoEditorHistory); + } + + document.getElementById('pe-rotate-left').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 270) % 360; + })); + document.getElementById('pe-rotate-right').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 90) % 360; + })); + document.getElementById('pe-flip-h').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.flip_h = !photoEditorState.operations.flip_h; + })); + document.getElementById('pe-flip-v').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.flip_v = !photoEditorState.operations.flip_v; + })); + + document.getElementById('pe-reset-adjustments').addEventListener('click', () => mutatePhotoEditor(() => { + for (const property of ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) { + photoEditorState.operations[property] = 0; + } + })); + + document.getElementById('pe-crop-toggle').addEventListener('click', toggleCropMode); + document.getElementById('pe-crop-apply').addEventListener('click', applyCropSelection); + document.getElementById('pe-crop-cancel').addEventListener('click', cancelCropMode); + document.getElementById('pe-crop-ratio').addEventListener('change', setDefaultCropSelection); + + document.getElementById('pe-remove-background').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.remove_background = event.target.checked; + if (event.target.checked && photoEditorState.operations.format === 'jpeg') photoEditorState.operations.format = 'png'; + })); + document.getElementById('pe-background-sensitivity').addEventListener('input', event => { + photoEditorState.operations.background_sensitivity = Number(event.target.value); + document.getElementById('pe-background-sensitivity-val').textContent = `${event.target.value}%`; + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-background-sensitivity').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-watermark-enabled').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.watermark.enabled = event.target.checked; + })); + document.getElementById('pe-watermark-text').addEventListener('input', event => { + photoEditorState.operations.watermark.text = event.target.value.slice(0, 80); + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-watermark-text').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-watermark-position').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.watermark.position = event.target.value; + })); + document.getElementById('pe-watermark-opacity').addEventListener('input', event => { + photoEditorState.operations.watermark.opacity = Number(event.target.value); + document.getElementById('pe-watermark-opacity-val').textContent = `${event.target.value}%`; + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-watermark-opacity').addEventListener('change', pushPhotoEditorHistory); + + const cropOverlay = document.getElementById('photo-editor-crop-overlay'); + cropOverlay.addEventListener('mousedown', cropMouseDown); + cropOverlay.addEventListener('mousemove', cropMouseMove); + cropOverlay.addEventListener('mouseup', cropMouseUp); + cropOverlay.addEventListener('mouseleave', cropMouseUp); + cropOverlay.addEventListener('touchstart', event => { event.preventDefault(); cropMouseDown(touchToMouse(event)); }, { passive: false }); + cropOverlay.addEventListener('touchmove', event => { event.preventDefault(); cropMouseMove(touchToMouse(event)); }, { passive: false }); + cropOverlay.addEventListener('touchend', cropMouseUp); + + document.getElementById('pe-format').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.format = event.target.value; + }, false)); + document.getElementById('pe-quality').addEventListener('input', event => { + photoEditorState.operations.quality = Number(event.target.value); + document.getElementById('pe-quality-val').textContent = `${event.target.value}%`; + }); + document.getElementById('pe-quality').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-target-size').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-target-width').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-target-height').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-apply').addEventListener('click', applyPhotoEdit); + + window.addEventListener('resize', () => { + if (!document.getElementById('photo-editor-overlay').classList.contains('hidden')) syncCropOverlay(); + }); + document.addEventListener('keydown', event => { + const overlay = document.getElementById('photo-editor-overlay'); + if (!overlay || overlay.classList.contains('hidden')) return; + if (event.key === 'Escape') { + event.preventDefault(); + closePhotoEditor(); + return; + } + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return; + event.preventDefault(); + stepPhotoEditorHistory(event.shiftKey ? 1 : -1); + }); +} + +function updatePhotoEditorRangeValue(property, digits = 0, suffix = '') { + const value = Number(photoEditorState.operations[property] || 0); + const text = digits ? value.toFixed(digits).replace('.', ',') : String(Math.round(value)); + document.getElementById(`pe-${property}-val`).textContent = `${text}${suffix}`; +} + +function updatePhotoEditorControls() { + for (const property of ['straighten', 'exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) { + const input = document.getElementById(`pe-${property}`); + input.value = photoEditorState.operations[property]; + } + updatePhotoEditorRangeValue('straighten', 1, '°'); + updatePhotoEditorRangeValue('exposure', 1); + for (const property of ['brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) updatePhotoEditorRangeValue(property); + document.getElementById('pe-format').value = photoEditorState.operations.format; + document.getElementById('pe-quality').value = photoEditorState.operations.quality; + document.getElementById('pe-quality-val').textContent = `${photoEditorState.operations.quality}%`; + document.getElementById('pe-remove-background').checked = Boolean(photoEditorState.operations.remove_background); + document.getElementById('pe-background-sensitivity').value = Number(photoEditorState.operations.background_sensitivity ?? 45); + document.getElementById('pe-background-sensitivity-val').textContent = `${Number(photoEditorState.operations.background_sensitivity ?? 45)}%`; + const watermark = photoEditorState.operations.watermark || { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 }; + document.getElementById('pe-watermark-enabled').checked = Boolean(watermark.enabled); + document.getElementById('pe-watermark-text').value = watermark.text || 'Vendoo'; + document.getElementById('pe-watermark-position').value = watermark.position || 'bottom-right'; + document.getElementById('pe-watermark-opacity').value = Number(watermark.opacity || 40); + document.getElementById('pe-watermark-opacity-val').textContent = `${Number(watermark.opacity || 40)}%`; + + const { width, height } = photoEditorState.operations.resize || {}; + const target = width && height && width === height && [1200, 1600, 2048].includes(width) ? String(width) : width || height ? 'custom' : 'original'; + document.getElementById('pe-target-size').value = target; + document.getElementById('pe-custom-size').classList.toggle('hidden', target !== 'custom'); + document.getElementById('pe-target-width').value = target === 'custom' && width ? width : ''; + document.getElementById('pe-target-height').value = target === 'custom' && height ? height : ''; +} + +function mutatePhotoEditor(mutator, render = true) { + mutator(); + updatePhotoEditorControls(); + pushPhotoEditorHistory(); + if (render) renderPhotoEditorCanvas(); +} + +function pushPhotoEditorHistory() { + const snapshot = cloneEditorOperations(); + const current = photoEditorState.history[photoEditorState.historyIndex]; + if (current && JSON.stringify(current) === JSON.stringify(snapshot)) return; + photoEditorState.history = photoEditorState.history.slice(0, photoEditorState.historyIndex + 1); + photoEditorState.history.push(snapshot); + photoEditorState.historyIndex = photoEditorState.history.length - 1; + updatePhotoEditorHistoryControls(); +} + +function stepPhotoEditorHistory(direction) { + const next = photoEditorState.historyIndex + direction; + if (next < 0 || next >= photoEditorState.history.length) return; + photoEditorState.historyIndex = next; + photoEditorState.operations = cloneEditorOperations(photoEditorState.history[next]); + cancelCropMode(false); + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); + renderPhotoEditorCanvas(); +} + +function updatePhotoEditorHistoryControls() { + document.getElementById('pe-undo').disabled = photoEditorState.historyIndex <= 0; + document.getElementById('pe-redo').disabled = photoEditorState.historyIndex >= photoEditorState.history.length - 1; + document.getElementById('pe-history-status').textContent = `Schritt ${Math.max(1, photoEditorState.historyIndex + 1)} von ${Math.max(1, photoEditorState.history.length)}`; +} + +function updatePhotoEditorResize() { + const target = document.getElementById('pe-target-size').value; + const custom = document.getElementById('pe-custom-size'); + custom.classList.toggle('hidden', target !== 'custom'); + if (target === 'original') photoEditorState.operations.resize = { width: 0, height: 0 }; + else if (target === 'custom') { + photoEditorState.operations.resize = { + width: Math.max(0, Number(document.getElementById('pe-target-width').value) || 0), + height: Math.max(0, Number(document.getElementById('pe-target-height').value) || 0), + }; + } else { + const size = Number(target); + photoEditorState.operations.resize = { width: size, height: size }; + } + pushPhotoEditorHistory(); + renderPhotoEditorCanvas(); +} + +function loadEditorImage(path) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Bildvorschau konnte nicht geladen werden.')); + image.src = `${photoUrl(path)}${String(photoUrl(path)).includes('?') ? '&' : '?'}editor=${Date.now()}`; + }); +} + +async function openPhotoEditor(index, context = 'generator') { + const photos = context === 'flux' ? (state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []) : context === 'media' ? (state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []) : context === 'detail' ? (detailEditorState?.photos || []) : state.photos; + const photo = photos[index]; + if (!photo) return; + + photoEditorState = { + context, + platform: context === 'flux' ? 'ai-generated' : context === 'media' ? (state.mediaCurrent?.source || 'media-library') : context === 'detail' ? (detailEditorState?.listing?.platform || '') : state.platform, + index, + currentPath: photo, + rootPath: photo, + sourceImage: null, + rootImage: null, + operations: createPhotoEditorOperations(), + history: [], historyIndex: -1, versions: [], compare: false, + cropping: false, cropRect: null, cropStart: null, cropEnd: null, + _dragOrigin: null, _dragging: false, + }; + + const overlay = document.getElementById('photo-editor-overlay'); + overlay.classList.remove('hidden'); + overlay.setAttribute('aria-hidden', 'false'); + document.getElementById('pe-image-meta').textContent = 'Versionen werden geladen …'; + document.getElementById('pe-version-list').innerHTML = '
                          Versionen werden geladen …
                          '; + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); + setPhotoEditorCompare(false); + + try { + const history = await api('GET', `/api/image-editor/versions?path=${encodeURIComponent(photo)}`); + photoEditorState.rootPath = history.root_path || photo; + photoEditorState.versions = [history.original, ...(history.versions || [])].filter(Boolean); + const [sourceImage, rootImage] = await Promise.all([ + loadEditorImage(photo), + history.root_path && history.root_path !== photo ? loadEditorImage(history.root_path) : loadEditorImage(photo), + ]); + photoEditorState.sourceImage = sourceImage; + photoEditorState.rootImage = rootImage; + resetPhotoEditorSession(); + renderPhotoEditorVersions(); + renderPhotoEditorCanvas(); + } catch (error) { + toast(error.message || 'Bildeditor konnte nicht geöffnet werden.'); + closePhotoEditor(); + } +} + +function resetPhotoEditorSession() { + const extension = String(photoEditorState.currentPath || '').split('.').pop()?.toLowerCase(); + photoEditorState.operations = createPhotoEditorOperations(); + if (extension === 'png') photoEditorState.operations.format = 'png'; + else if (extension === 'webp') photoEditorState.operations.format = 'webp'; + photoEditorState.history = [cloneEditorOperations()]; + photoEditorState.historyIndex = 0; + cancelCropMode(false); + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); +} + +async function loadPhotoEditorVersion(path) { + if (!path || path === photoEditorState.currentPath) return; + try { + document.getElementById('pe-image-meta').textContent = 'Version wird geladen …'; + photoEditorState.sourceImage = await loadEditorImage(path); + photoEditorState.currentPath = path; + resetPhotoEditorSession(); + renderPhotoEditorVersions(); + renderPhotoEditorCanvas(); + } catch (error) { toast(error.message); } +} + +function formatEditorFileSize(bytes) { + const size = Number(bytes) || 0; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`; + return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`; +} + +function renderPhotoEditorVersions() { + const list = document.getElementById('pe-version-list'); + const versions = photoEditorState.versions || []; + document.getElementById('pe-version-count').textContent = String(Math.max(0, versions.length - 1)); + if (!versions.length) { + list.innerHTML = '
                          Noch keine Bearbeitungsversion.
                          '; + return; + } + list.innerHTML = versions.map((version, index) => { + const path = version.output_path || version.root_path; + const isOriginal = version.id === 'original'; + const date = version.created_at ? new Date(version.created_at).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; + const dimensions = version.width && version.height ? `${version.width} × ${version.height}` : ''; + return ``; + }).join(''); + list.querySelectorAll('[data-version-path]').forEach(button => button.addEventListener('click', () => loadPhotoEditorVersion(button.dataset.versionPath))); +} + +function createPreviewCanvas(image, operations, { applyCrop = true, maxDimension = 1100 } = {}) { + const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height)); + const sourceWidth = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); + const sourceHeight = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); + const angle = (operations.rotation + operations.straighten) * Math.PI / 180; + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + const outputWidth = Math.max(1, Math.ceil(sourceWidth * cos + sourceHeight * sin)); + const outputHeight = Math.max(1, Math.ceil(sourceWidth * sin + sourceHeight * cos)); + const geometry = document.createElement('canvas'); + geometry.width = outputWidth; + geometry.height = outputHeight; + const geometryContext = geometry.getContext('2d', { willReadFrequently: true }); + geometryContext.fillStyle = '#ffffff'; + geometryContext.fillRect(0, 0, outputWidth, outputHeight); + geometryContext.save(); + geometryContext.translate(outputWidth / 2, outputHeight / 2); + geometryContext.rotate(angle); + geometryContext.scale(operations.flip_h ? -1 : 1, operations.flip_v ? -1 : 1); + geometryContext.drawImage(image, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight); + geometryContext.restore(); + + let result = geometry; + if (applyCrop && operations.crop) { + const left = Math.max(0, Math.min(geometry.width - 1, Math.round(operations.crop.x * geometry.width))); + const top = Math.max(0, Math.min(geometry.height - 1, Math.round(operations.crop.y * geometry.height))); + const width = Math.max(1, Math.min(geometry.width - left, Math.round(operations.crop.width * geometry.width))); + const height = Math.max(1, Math.min(geometry.height - top, Math.round(operations.crop.height * geometry.height))); + result = document.createElement('canvas'); + result.width = width; + result.height = height; + result.getContext('2d', { willReadFrequently: true }).drawImage(geometry, left, top, width, height, 0, 0, width, height); + } + + applyPreviewToneAdjustments(result, operations); + applyPreviewBackgroundRemoval(result, operations); + applyPreviewWatermark(result, operations); + return result; +} + +function applyPreviewBackgroundRemoval(canvas, operations) { + if (!operations.remove_background) return; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const cornerIndexes = [0, (canvas.width - 1) * 4, ((canvas.height - 1) * canvas.width) * 4, (canvas.width * canvas.height - 1) * 4]; + const background = cornerIndexes.reduce((sum, index) => ({ r: sum.r + data[index], g: sum.g + data[index + 1], b: sum.b + data[index + 2] }), { r: 0, g: 0, b: 0 }); + background.r /= 4; background.g /= 4; background.b /= 4; + const level = Math.max(0, Math.min(100, Number(operations.background_sensitivity ?? 45))); + const solidThreshold = 24 + level * 2.1; + const featherWidth = 45 + level * 1.2; + const featherEnd = solidThreshold + featherWidth; + for (let index = 0; index < data.length; index += 4) { + const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); + if (distance <= solidThreshold) data[index + 3] = 0; + else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth); + } + context.putImageData(imageData, 0, 0); +} + +function applyPreviewWatermark(canvas, operations) { + const watermark = operations.watermark || {}; + const text = String(watermark.text || '').trim(); + if (!watermark.enabled || !text) return; + const context = canvas.getContext('2d'); + const margin = Math.max(10, Math.round(Math.min(canvas.width, canvas.height) * 0.025)); + const fontSize = Math.max(16, Math.round(canvas.width * 0.04)); + const position = watermark.position || 'bottom-right'; + let x = canvas.width - margin; let y = canvas.height - margin; let align = 'right'; let baseline = 'bottom'; + if (position.includes('left')) { x = margin; align = 'left'; } + if (position.includes('top')) { y = margin; baseline = 'top'; } + if (position === 'center') { x = canvas.width / 2; y = canvas.height / 2; align = 'center'; baseline = 'middle'; } + context.save(); + context.globalAlpha = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100)); + context.font = `700 ${fontSize}px system-ui, sans-serif`; + context.textAlign = align; + context.textBaseline = baseline; + context.lineWidth = Math.max(2, fontSize * 0.08); + context.strokeStyle = 'rgba(0,0,0,.42)'; + context.fillStyle = '#ffffff'; + context.strokeText(text, x, y); + context.fillText(text, x, y); + context.restore(); +} + +function applyPreviewToneAdjustments(canvas, operations) { + const context = canvas.getContext('2d', { willReadFrequently: true }); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const exposure = 2 ** operations.exposure; + const brightness = operations.brightness / 100 * 0.22; + const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100)); + const contrast = (1 + contrastValue) / (1 - contrastValue); + const highlights = operations.highlights / 100; + const shadows = operations.shadows / 100; + const temperature = operations.temperature / 100; + const saturation = 1 + operations.saturation / 100; + + for (let index = 0; index < data.length; index += 4) { + let red = data[index] / 255; + let green = data[index + 1] / 255; + let blue = data[index + 2] / 255; + red = (red * exposure + brightness - 0.5) * contrast + 0.5; + green = (green * exposure + brightness - 0.5) * contrast + 0.5; + blue = (blue * exposure + brightness - 0.5) * contrast + 0.5; + const lumBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722)); + const shadowWeight = (1 - lumBefore) ** 2; + const highlightWeight = lumBefore ** 2; + const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28; + const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38; + red += shadowDelta + highlightDelta + temperature * 0.12; + green += shadowDelta + highlightDelta + temperature * 0.015; + blue += shadowDelta + highlightDelta - temperature * 0.12; + const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; + red = luminance + (red - luminance) * saturation; + green = luminance + (green - luminance) * saturation; + blue = luminance + (blue - luminance) * saturation; + data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255); + data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255); + data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255); + } + context.putImageData(imageData, 0, 0); + if (operations.sharpness > 0) applyPreviewSharpen(canvas, operations.sharpness / 100); +} + +function applyPreviewSharpen(canvas, strength) { + if (canvas.width * canvas.height > 1_500_000) return; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const source = context.getImageData(0, 0, canvas.width, canvas.height); + const output = context.createImageData(canvas.width, canvas.height); + output.data.set(source.data); + const amount = Math.min(1, Math.max(0, strength)) * 0.75; + const width = canvas.width; + for (let y = 1; y < canvas.height - 1; y += 1) { + for (let x = 1; x < width - 1; x += 1) { + const index = (y * width + x) * 4; + for (let channel = 0; channel < 3; channel += 1) { + const center = source.data[index + channel]; + const average = (source.data[index - 4 + channel] + source.data[index + 4 + channel] + source.data[index - width * 4 + channel] + source.data[index + width * 4 + channel]) / 4; + output.data[index + channel] = Math.max(0, Math.min(255, center + (center - average) * amount)); + } + } + } + context.putImageData(output, 0, 0); +} + +function calculatePhotoEditorOutputDimensions() { + const image = photoEditorState.sourceImage; + if (!image) return { width: 0, height: 0 }; + const angle = (photoEditorState.operations.rotation + photoEditorState.operations.straighten) * Math.PI / 180; + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + let width = Math.ceil(image.naturalWidth * cos + image.naturalHeight * sin); + let height = Math.ceil(image.naturalWidth * sin + image.naturalHeight * cos); + if (photoEditorState.operations.crop) { + width = Math.max(1, Math.round(width * photoEditorState.operations.crop.width)); + height = Math.max(1, Math.round(height * photoEditorState.operations.crop.height)); + } + const target = photoEditorState.operations.resize || {}; + if (target.width || target.height) { + const scale = Math.min(target.width ? target.width / width : Infinity, target.height ? target.height / height : Infinity, 1); + width = Math.max(1, Math.round(width * scale)); + height = Math.max(1, Math.round(height * scale)); + } + return { width, height }; +} + +function renderPhotoEditorCanvas() { + const image = photoEditorState.sourceImage; + if (!image) return; + const preview = createPreviewCanvas(image, photoEditorState.operations, { applyCrop: !photoEditorState.cropping }); + const canvas = document.getElementById('photo-editor-canvas'); + canvas.width = preview.width; + canvas.height = preview.height; + canvas.getContext('2d').drawImage(preview, 0, 0); + + if (photoEditorState.rootImage) { + const before = createPreviewCanvas(photoEditorState.rootImage, createPhotoEditorOperations(), { maxDimension: 850 }); + const beforeCanvas = document.getElementById('photo-editor-before-canvas'); + beforeCanvas.width = before.width; + beforeCanvas.height = before.height; + beforeCanvas.getContext('2d').drawImage(before, 0, 0); + } + + const dimensions = calculatePhotoEditorOutputDimensions(); + document.getElementById('pe-output-dimensions').textContent = dimensions.width && dimensions.height ? `Ausgabe: ${dimensions.width} × ${dimensions.height} px` : 'Ausgabegröße wird ermittelt'; + document.getElementById('pe-image-meta').textContent = `${photoEditorState.currentPath === photoEditorState.rootPath ? 'Original' : 'Version'} · ${image.naturalWidth} × ${image.naturalHeight} px · Original bleibt erhalten`; + requestAnimationFrame(syncCropOverlay); +} + +function setPhotoEditorCompare(enabled) { + photoEditorState.compare = Boolean(enabled); + document.getElementById('pe-before-pane').classList.toggle('hidden', !photoEditorState.compare); + const button = document.getElementById('pe-compare'); + button.setAttribute('aria-pressed', String(photoEditorState.compare)); + button.classList.toggle('active', photoEditorState.compare); +} + +function togglePhotoEditorCompare() { + setPhotoEditorCompare(!photoEditorState.compare); + requestAnimationFrame(syncCropOverlay); +} + +function touchToMouse(event) { + const touch = event.touches?.[0] || event.changedTouches?.[0]; + return { clientX: touch?.clientX || 0, clientY: touch?.clientY || 0, target: event.target }; +} + +function toggleCropMode() { + if (photoEditorState.cropping) return cancelCropMode(); + photoEditorState.cropping = true; + document.getElementById('pe-crop-toggle').classList.add('active'); + document.getElementById('pe-crop-toggle').textContent = 'Auswahl aktiv'; + document.getElementById('pe-crop-apply').classList.remove('hidden'); + document.getElementById('pe-crop-cancel').classList.remove('hidden'); + document.getElementById('pe-crop-hint').textContent = 'Rahmen aufziehen oder verschieben; anschließend anwenden.'; + renderPhotoEditorCanvas(); + requestAnimationFrame(() => { + syncCropOverlay(); + if (photoEditorState.operations.crop) { + const overlay = document.getElementById('photo-editor-crop-overlay'); + photoEditorState.cropRect = { + x: photoEditorState.operations.crop.x * overlay.width, + y: photoEditorState.operations.crop.y * overlay.height, + w: photoEditorState.operations.crop.width * overlay.width, + h: photoEditorState.operations.crop.height * overlay.height, + }; + drawCropOverlay(); + } else setDefaultCropSelection(); + }); +} + +function cancelCropMode(render = true) { + photoEditorState.cropping = false; + photoEditorState.cropRect = null; + photoEditorState.cropStart = null; + photoEditorState.cropEnd = null; + photoEditorState._dragOrigin = null; + photoEditorState._dragging = false; + const overlay = document.getElementById('photo-editor-crop-overlay'); + overlay.classList.add('hidden'); + clearCropOverlay(); + const toggle = document.getElementById('pe-crop-toggle'); + toggle.classList.remove('active'); + toggle.textContent = '✂ Auswahl starten'; + document.getElementById('pe-crop-apply').classList.add('hidden'); + document.getElementById('pe-crop-cancel').classList.add('hidden'); + document.getElementById('pe-crop-hint').textContent = photoEditorState.operations.crop ? 'Ein nicht destruktiver Zuschnitt ist aktiv.' : 'Zuschnitt ist nicht aktiv.'; + if (render) renderPhotoEditorCanvas(); +} + +function syncCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const canvas = document.getElementById('photo-editor-canvas'); + const wrap = document.getElementById('photo-editor-canvas-wrap'); + if (!photoEditorState.cropping || !canvas.offsetWidth || !canvas.offsetHeight) { + overlay.classList.add('hidden'); + return; + } + const canvasRect = canvas.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + overlay.width = Math.max(1, Math.round(canvasRect.width)); + overlay.height = Math.max(1, Math.round(canvasRect.height)); + overlay.style.width = `${canvasRect.width}px`; + overlay.style.height = `${canvasRect.height}px`; + overlay.style.left = `${canvasRect.left - wrapRect.left}px`; + overlay.style.top = `${canvasRect.top - wrapRect.top}px`; + overlay.classList.remove('hidden'); + drawCropOverlay(); +} + +function getCropRatio() { + const value = document.getElementById('pe-crop-ratio').value; + if (value === 'free') return null; + if (value === 'original') { + const canvas = document.getElementById('photo-editor-canvas'); + return canvas.width / Math.max(1, canvas.height); + } + const [width, height] = value.split(':').map(Number); + return width && height ? width / height : null; +} + +function setDefaultCropSelection() { + if (!photoEditorState.cropping) return; + const overlay = document.getElementById('photo-editor-crop-overlay'); + const ratio = getCropRatio(); + const margin = Math.min(overlay.width, overlay.height) * 0.08; + let width = Math.max(20, overlay.width - margin * 2); + let height = Math.max(20, overlay.height - margin * 2); + if (ratio) { + if (width / height > ratio) width = height * ratio; + else height = width / ratio; + } + photoEditorState.cropRect = { x: (overlay.width - width) / 2, y: (overlay.height - height) / 2, w: width, h: height }; + drawCropOverlay(); +} + +function getCropCanvasCoords(event) { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const rect = overlay.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(overlay.width, event.clientX - rect.left)), + y: Math.max(0, Math.min(overlay.height, event.clientY - rect.top)), + }; +} + +function cropMouseDown(event) { + if (!photoEditorState.cropping) return; + photoEditorState._dragOrigin = getCropCanvasCoords(event); + photoEditorState._dragging = true; + photoEditorState.cropStart = photoEditorState._dragOrigin; + photoEditorState.cropEnd = photoEditorState._dragOrigin; +} + +function cropMouseMove(event) { + if (!photoEditorState.cropping || !photoEditorState._dragging || !photoEditorState._dragOrigin) return; + const overlay = document.getElementById('photo-editor-crop-overlay'); + const origin = photoEditorState._dragOrigin; + let point = getCropCanvasCoords(event); + const ratio = getCropRatio(); + if (ratio) { + const directionX = point.x >= origin.x ? 1 : -1; + const directionY = point.y >= origin.y ? 1 : -1; + let width = Math.abs(point.x - origin.x); + let height = Math.abs(point.y - origin.y); + if (width / Math.max(1, height) > ratio) height = width / ratio; + else width = height * ratio; + width = Math.min(width, directionX > 0 ? overlay.width - origin.x : origin.x); + height = Math.min(height, directionY > 0 ? overlay.height - origin.y : origin.y); + if (width / Math.max(1, height) > ratio) width = height * ratio; + else height = width / ratio; + point = { x: origin.x + width * directionX, y: origin.y + height * directionY }; + } + photoEditorState.cropEnd = point; + const x = Math.min(origin.x, point.x); + const y = Math.min(origin.y, point.y); + photoEditorState.cropRect = { x, y, w: Math.abs(point.x - origin.x), h: Math.abs(point.y - origin.y) }; + drawCropOverlay(); +} + +function cropMouseUp() { + if (!photoEditorState.cropping) return; + photoEditorState._dragging = false; + photoEditorState._dragOrigin = null; +} + +function drawCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const context = overlay.getContext('2d'); + context.clearRect(0, 0, overlay.width, overlay.height); + if (!photoEditorState.cropping) return; + context.fillStyle = 'rgba(7, 11, 17, 0.58)'; + context.fillRect(0, 0, overlay.width, overlay.height); + const rect = photoEditorState.cropRect; + if (!rect || rect.w < 2 || rect.h < 2) return; + context.clearRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = '#ffffff'; + context.lineWidth = 2; + context.setLineDash([]); + context.strokeRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = 'rgba(255,255,255,.42)'; + context.lineWidth = 1; + for (let index = 1; index < 3; index += 1) { + context.beginPath(); context.moveTo(rect.x + rect.w * index / 3, rect.y); context.lineTo(rect.x + rect.w * index / 3, rect.y + rect.h); context.stroke(); + context.beginPath(); context.moveTo(rect.x, rect.y + rect.h * index / 3); context.lineTo(rect.x + rect.w, rect.y + rect.h * index / 3); context.stroke(); + } + const canvas = document.getElementById('photo-editor-canvas'); + const realWidth = Math.round(rect.w * canvas.width / overlay.width); + const realHeight = Math.round(rect.h * canvas.height / overlay.height); + const label = `${realWidth} × ${realHeight}`; + context.font = '12px ui-monospace, monospace'; + const textWidth = context.measureText(label).width; + context.fillStyle = 'rgba(0,0,0,.74)'; + context.fillRect(rect.x + rect.w / 2 - textWidth / 2 - 7, Math.max(rect.y + 5, rect.y + rect.h - 25), textWidth + 14, 20); + context.fillStyle = '#ffffff'; + context.textAlign = 'center'; + context.fillText(label, rect.x + rect.w / 2, Math.max(rect.y + 19, rect.y + rect.h - 11)); +} + +function clearCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height); +} + +function applyCropSelection() { + const rect = photoEditorState.cropRect; + const overlay = document.getElementById('photo-editor-crop-overlay'); + if (!rect || rect.w < 10 || rect.h < 10) return toast('Bitte zuerst einen gültigen Zuschnitt wählen.'); + photoEditorState.operations.crop = { + x: Math.max(0, Math.min(1, rect.x / overlay.width)), + y: Math.max(0, Math.min(1, rect.y / overlay.height)), + width: Math.max(0.01, Math.min(1, rect.w / overlay.width)), + height: Math.max(0.01, Math.min(1, rect.h / overlay.height)), + }; + cancelCropMode(false); + pushPhotoEditorHistory(); + renderPhotoEditorCanvas(); +} + +async function restorePhotoEditorOriginal() { + if (!photoEditorState.rootPath) return; + try { + await commitPhotoEditorResult(photoEditorState.rootPath); + toast('Unverändertes Original wiederhergestellt.'); + closePhotoEditor(); + } catch (error) { toast(error.message || 'Original konnte nicht wiederhergestellt werden.'); } +} + +async function applyPhotoEdit() { + const button = document.getElementById('pe-apply'); + const originalLabel = button.textContent; + button.disabled = true; + button.textContent = 'Neue Version wird gerendert …'; + try { + const result = await api('POST', '/api/image-editor/render', { + source_path: photoEditorState.currentPath, + context: photoEditorState.context, + platform: getPhotoEditorPlatform(), + operations: photoEditorState.operations, + }); + await commitPhotoEditorResult(result.filename); + toast(`Neue Bildversion gespeichert (${result.metadata?.width || '?'} × ${result.metadata?.height || '?'} px).`); + closePhotoEditor(); + } catch (error) { + toast(error.message || 'Bildversion konnte nicht gespeichert werden.'); + } finally { + button.disabled = false; + button.textContent = originalLabel; + } +} + +function closePhotoEditor() { + const overlay = document.getElementById('photo-editor-overlay'); + overlay.classList.add('hidden'); + overlay.setAttribute('aria-hidden', 'true'); + cancelCropMode(false); + photoEditorState.sourceImage = null; + photoEditorState.rootImage = null; + photoEditorState.versions = []; +} + + +// --- Photo Drag & Drop Sorting --- +function enablePhotoDragSort() { + const container = document.getElementById('photo-preview'); + let dragIdx = -1; + + container.addEventListener('dragstart', e => { + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + dragIdx = [...container.children].indexOf(thumb); + thumb.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const thumb = e.target.closest('.thumb'); + if (thumb) { + container.querySelectorAll('.thumb').forEach(t => t.classList.remove('drag-over')); + thumb.classList.add('drag-over'); + } + }); + + container.addEventListener('drop', e => { + e.preventDefault(); + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + const dropIdx = [...container.children].indexOf(thumb); + if (dragIdx >= 0 && dropIdx >= 0 && dragIdx !== dropIdx) { + const moved = state.photos.splice(dragIdx, 1)[0]; + state.photos.splice(dropIdx, 0, moved); + renderPhotoPreview(); + } + dragIdx = -1; + }); + + container.addEventListener('dragend', () => { + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + dragIdx = -1; + }); +} + +// --- Price Suggestion --- +async function fetchPriceSuggestion() { + const title = document.getElementById('result-title')?.value; + const category = document.getElementById('meta-category-picker')?.textContent; + if (!title) return; + try { + const data = await api('GET', `/api/price-suggest?title=${encodeURIComponent(title)}&category=${encodeURIComponent(category || '')}`); + if (data.suggestion) { + const priceInput = document.getElementById('meta-price'); + const currentPrice = parseFloat(priceInput.value); + const hint = document.getElementById('price-suggestion-hint'); + const html = `Preisvorschlag: ${data.suggestion}€ (${data.min}–${data.max}€, ${data.count} ähnliche)`; + if (hint) { + hint.innerHTML = html; + hint.classList.remove('hidden'); + } else { + const el = document.createElement('div'); + el.id = 'price-suggestion-hint'; + el.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:4px;cursor:pointer'; + el.innerHTML = html; + el.addEventListener('click', () => { priceInput.value = data.suggestion; }); + priceInput.parentElement.appendChild(el); + } + } + } catch {} +} + +// --- Backup & Restore (now handled in setupAdmin for system tab) --- +function setupBackup() { + // Legacy backup buttons in settings - no longer exist, backup is in admin > system tab +} + +// --- Browser Notifications --- +function setupNotifications() { + if (!('Notification' in window)) return; + if (Notification.permission === 'default') { + Notification.requestPermission(); + } +} + +function notify(title, body) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + new Notification(title, { body, icon: '/favicon.ico' }); +} + +// --- Permissions & collaborative editing --- +function userCan(permission) { + return Boolean(currentUser?.role === 'admin' || currentUser?.permissions?.includes(permission)); +} + +function applyPermissionUi() { + const rules = [ + ['generator', 'listings.edit'], ['image-factory', 'batch.execute'], ['flux-studio', 'generation.execute'], + ['publish', 'publishing.view'], ['quality-center', 'listings.view'], ['inventory', 'listings.view'], + ]; + for (const [tab, permission] of rules) { + const button = document.querySelector(`.nav-item[data-tab="${tab}"]`); + if (button) button.classList.toggle('hidden', !userCan(permission)); + } + document.querySelectorAll('[data-requires-permission]').forEach(element => { + const permission = element.dataset.requiresPermission; + element.classList.toggle('hidden', !userCan(permission)); + if ('disabled' in element) element.disabled = !userCan(permission); + }); + document.body.dataset.userRole = currentUser?.role || 'viewer'; +} + +function clearListingLockTimer() { + if (listingEditLock.timer) window.clearInterval(listingEditLock.timer); + listingEditLock.timer = null; +} + +async function releaseListingLock({ quiet = true, keepalive = false } = {}) { + clearListingLockTimer(); + if (!listingEditLock.listingId || !listingEditLock.token) { + listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null; + return; + } + const id = listingEditLock.listingId; + const token = listingEditLock.token; + listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null; + try { + if (keepalive) { + await fetch(`/api/locks/listing/${id}`, { + method: 'DELETE', credentials: 'same-origin', keepalive: true, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ token }), + }); + } else await api('DELETE', `/api/locks/listing/${id}`, { token }, { retry: false, timeoutMs: 8000 }); + } catch (error) { if (!quiet) toast(error.message || 'Bearbeitungssperre konnte nicht gelöst werden.'); } +} + +function showListingLockBanner(lock, owned = false) { + document.getElementById('listing-lock-banner')?.remove(); + const detail = document.querySelector('.listing-editor-shell'); + if (!detail || !lock) return; + const banner = document.createElement('div'); + banner.id = 'listing-lock-banner'; + banner.className = `listing-lock-banner ${owned ? 'is-mine' : 'is-foreign'}`; + banner.innerHTML = owned + ? `
                          Bearbeitung für dich reserviert

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

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

                          Du kannst den Artikel ansehen. Speichern ist bis zum Ablauf der Sperre deaktiviert.

                          `; + detail.prepend(banner); + const save = document.getElementById('detail-save'); + if (save && !owned) { save.disabled = true; save.title = 'Artikel ist durch einen anderen Benutzer gesperrt'; } +} + +async function acquireListingLock(listingId) { + await releaseListingLock({ quiet: true }); + if (!listingId || !userCan('listings.edit')) return { acquired: false, readOnly: true }; + try { + const result = await api('POST', `/api/locks/listing/${listingId}/acquire`, { client_label: `${navigator.platform || 'Browser'} · Artikel-Editor`, ttl_seconds: 120 }); + listingEditLock.listingId = Number(listingId); + listingEditLock.token = result.token; + listingEditLock.lock = result.lock; + showListingLockBanner(result.lock, true); + listingEditLock.timer = window.setInterval(async () => { + if (!listingEditLock.listingId || !listingEditLock.token) return; + try { + const renewed = await api('POST', `/api/locks/listing/${listingEditLock.listingId}/renew`, { token: listingEditLock.token, ttl_seconds: 120 }, { timeoutMs: 10000, retry: false }); + listingEditLock.lock = renewed.lock; + } catch (error) { + clearListingLockTimer(); + const save = document.getElementById('detail-save'); + if (save) save.disabled = true; + toast(`Bearbeitungssperre verloren: ${error.message}`); + } + }, 45000); + return { acquired: true, lock: result.lock }; + } catch (error) { + if (error.status === 409) { + showListingLockBanner(error.lock || { user_name: 'Ein anderer Benutzer' }, false); + return { acquired: false, readOnly: true, lock: error.lock }; + } + toast(`Bearbeitungssperre konnte nicht erstellt werden: ${error.message}`); + return { acquired: false, readOnly: true }; + } +} + +window.addEventListener('beforeunload', () => { releaseListingLock({ quiet: true, keepalive: true }); }); + +// --- User Menu --- +let currentUser = null; + +async function setupUserMenu() { + try { + currentUser = await api('GET', '/api/me'); + if (!currentUser || currentUser.setupMode) { + document.getElementById('user-menu').classList.add('hidden'); + return; + } + _csrfToken = currentUser.csrf || getCsrf(); + + // Populate sidebar user area + const initials = currentUser.initials || currentUser.name?.[0]?.toUpperCase() || '?'; + const sidebarAvatar = document.getElementById('sidebar-avatar'); + if (sidebarAvatar) { + sidebarAvatar.textContent = initials; + sidebarAvatar.style.background = currentUser.avatar_color || 'var(--accent)'; + } + const sidebarName = document.getElementById('sidebar-name'); + if (sidebarName) sidebarName.textContent = currentUser.name || currentUser.email || ''; + const sidebarRole = document.getElementById('sidebar-role'); + if (sidebarRole) sidebarRole.textContent = currentUser.role || ''; + const topbarAvatar = document.getElementById('topbar-avatar'); + if (topbarAvatar) { + topbarAvatar.textContent = initials; + topbarAvatar.style.background = currentUser.avatar_color || 'var(--vd-ink)'; + topbarAvatar.title = currentUser.name || currentUser.email || ''; + } + + const adminNav = document.getElementById('nav-admin-main'); + if (adminNav) adminNav.classList.toggle('hidden', !['admin', 'manager'].includes(currentUser.role)); + if (currentUser.role === 'manager') { + document.querySelectorAll('.admin-tab:not([data-admin-tab="audit"])').forEach(tab => tab.classList.add('hidden')); + document.getElementById('invite-user-btn')?.classList.add('hidden'); + adminNav?.setAttribute('data-admin-tab', 'audit'); + } + applyPermissionUi(); + + // Logout + document.getElementById('sidebar-logout-btn')?.addEventListener('click', async () => { + await releaseListingLock({ quiet: true }); + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken || getCsrf() } }); + window.location.href = '/login.html'; + }); + } catch { + document.getElementById('sidebar-user')?.classList.add('hidden'); + } +} + + +// --- Operations Center 1.30.0 --- +const operationsState = { status: null, backups: [], extensions: null, restoreFile: null, updateFile: null }; + +function operationFormatBytes(value) { + const bytes = Number(value || 0); + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / (1024 ** index)).toLocaleString('de-DE', { maximumFractionDigits: index ? 1 : 0 })} ${units[index]}`; +} + +function operationFormatDate(value) { + if (!value) return '—'; + try { return new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } + catch { return String(value); } +} + +async function operationsUploadRequest(url, field, file) { + const form = new FormData(); + form.append(field, file); + const response = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + return data; +} + +function renderOperationsStatus() { + const status = operationsState.status || {}; + const pending = status.pending; + const banner = document.getElementById('operations-pending-banner'); + if (banner) { + banner.classList.toggle('hidden', !pending); + if (pending) banner.innerHTML = `
                          ${pending.type === 'update' ? `Update auf ${escapeHtml(pending.version_to || 'neue Version')}` : 'Wiederherstellung'} ist vorbereitet

                          Vendoo beenden und setup.bat11 – Vorbereitete Operation anwenden ausführen.

                          `; + } + document.getElementById('operations-pending-cancel')?.addEventListener('click', async () => { + if (!confirm('Vorbereitete Operation wirklich verwerfen?')) return; + try { await api('POST', '/api/operations/pending/cancel'); toast('Vorbereitung verworfen.'); await loadOperationsCenter(); } + catch (error) { toast(error.message || 'Vorbereitung konnte nicht verworfen werden.'); } + }); + + const version = document.getElementById('operations-version-card'); + if (version) { + const last = status.last; + version.innerHTML = `Vendoo ${escapeHtml(status.version || VENDOO_UI_BUILD)}${last ? `Letzte Operation: ${escapeHtml(last.type || 'System')} · ${escapeHtml(last.status || 'unbekannt')}` : 'Noch keine Setup-Operation protokolliert.'}`; + } + const migrations = document.getElementById('operations-migration-list'); + if (migrations) { + const rows = status.migrations || []; + migrations.innerHTML = rows.length ? rows.slice(0, 8).map(row => `

                          ${escapeHtml(row.app_version)}${escapeHtml(row.description || row.migration_key)} · ${operationFormatDate(row.applied_at)}

                          `).join('') : '
                          Keine Migrationsinformationen vorhanden.
                          '; + } + + const settings = status.settings || {}; + const enabled = document.getElementById('operations-auto-enabled'); if (enabled) enabled.checked = !!settings.auto_backup_enabled; + const hour = document.getElementById('operations-auto-hour'); if (hour) hour.value = String(settings.auto_backup_hour ?? 3); + const retention = document.getElementById('operations-auto-retention'); if (retention) retention.value = String(settings.auto_backup_retention ?? 7); + const uploads = document.getElementById('operations-auto-uploads'); if (uploads) uploads.checked = settings.include_uploads_automatic !== false; + const updateUrl = document.getElementById('operations-update-url'); if (updateUrl && document.activeElement !== updateUrl) updateUrl.value = settings.update_manifest_url || ''; + window.VendooIcons?.render(document.getElementById('admin-system-panel')); +} + +function renderOperationsBackups() { + const list = document.getElementById('operations-backup-list'); + const summary = document.getElementById('operations-backup-summary'); + if (!list) return; + const backups = operationsState.backups || []; + const total = backups.reduce((sum, item) => sum + Number(item.file_size || 0), 0); + if (summary) summary.textContent = `${backups.length} Sicherung(en) · ${operationFormatBytes(total)}`; + if (!backups.length) { + list.innerHTML = '
                          Noch kein Backup vorhanden.
                          '; + return; + } + list.innerHTML = backups.map(item => { + const verified = item.status === 'verified'; + const includesUploads = item.options?.includeUploads; + return `
                          +
                          +
                          ${escapeHtml(item.label || item.file_name)}${escapeHtml(item.kind === 'automatic' ? 'Automatisch' : item.kind === 'database' ? 'Nur Datenbank' : 'Manuell')} · ${operationFormatDate(item.created_at)}${operationFormatBytes(item.file_size)} · ${includesUploads ? 'mit Uploads' : 'ohne Uploads'}${item.options?.includeConfig ? ' · enthält .env' : ''} · ${verified ? 'verifiziert' : escapeHtml(item.status)}
                          + ${escapeHtml(String(item.sha256 || '').slice(0, 16))}… +
                          + + + +
                          +
                          `; + }).join(''); + list.querySelectorAll('[data-operation-backup-verify]').forEach(button => button.addEventListener('click', async () => { + button.disabled = true; + try { + const result = await api('POST', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupVerify)}/verify`); + toast(result.ok ? 'Backup vollständig verifiziert.' : `Backup fehlerhaft: ${(result.errors || []).join(', ')}`); + await loadOperationsBackups(); + } catch (error) { toast(error.message); } + finally { button.disabled = false; } + })); + list.querySelectorAll('[data-operation-backup-delete]').forEach(button => button.addEventListener('click', async () => { + if (!confirm('Dieses Backup endgültig löschen?')) return; + try { await api('DELETE', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupDelete)}`); toast('Backup gelöscht.'); await loadOperationsBackups(); } + catch (error) { toast(error.message); } + })); + window.VendooIcons?.render(list); +} + +function renderOperationsExtensions() { + const data = operationsState.extensions || {}; + const summary = document.getElementById('operations-extension-summary'); + const packages = document.getElementById('operations-extension-packages'); + const clients = document.getElementById('operations-extension-clients'); + if (summary) summary.innerHTML = `
                          ${Number(data.connected || 0)} Browser live verbunden

                          Erwartete Extension-Version: ${escapeHtml(data.expected_version || '—')}

                          `; + if (packages) packages.innerHTML = Object.entries(data.packages || {}).map(([browser, item]) => `
                          ${escapeHtml(browser[0].toUpperCase() + browser.slice(1))}${item.exists ? `Paket v${escapeHtml(item.version || '?')}` : 'Paket fehlt'}${item.version && item.version !== data.expected_version ? ` · erwartet ${escapeHtml(data.expected_version)}` : ''}
                          `).join(''); + if (clients) { + const rows = data.clients || []; + clients.innerHTML = rows.length ? rows.map(client => { + const live = Number(client.age_seconds || 999999) <= 180; + return `
                          ${escapeHtml(client.browser || 'Browser')} · v${escapeHtml(client.version || '?')}${escapeHtml(client.platform || 'Extension')} · zuletzt ${Number(client.age_seconds || 0) < 60 ? 'gerade eben' : `vor ${Math.max(1, Math.round(Number(client.age_seconds || 0) / 60))} Min.`}${escapeHtml(client.server_url || '')}
                          ${live ? 'Live' : 'Offline'}
                          `; + }).join('') : '
                          Noch keine Extension-Verbindung gemeldet. Öffne nach dem Update die Extension oder eine unterstützte Vinted-Seite.
                          '; + } + window.VendooIcons?.render(document.getElementById('operations-extensions-panel')); +} + +async function loadOperationsBackups() { + try { operationsState.backups = (await api('GET', '/api/operations/backups')).backups || []; renderOperationsBackups(); } + catch (error) { const list = document.getElementById('operations-backup-list'); if (list) list.innerHTML = `
                          ${escapeHtml(error.message)}
                          `; } +} + +async function loadOperationsExtensions() { + try { operationsState.extensions = await api('GET', '/api/extensions/diagnostics'); renderOperationsExtensions(); } + catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `
                          ${escapeHtml(error.message)}
                          `; } +} + +async function loadOperationsCenter() { + if (!document.getElementById('operations-center-title')) return; + try { operationsState.status = await api('GET', '/api/operations/status'); renderOperationsStatus(); } catch (error) { toast(error.message); } + await Promise.all([loadOperationsBackups(), loadOperationsExtensions()]); +} + +function setOperationsTab(target) { + document.querySelectorAll('.operations-tab').forEach(button => button.classList.toggle('active', button.dataset.operationsTab === target)); + document.querySelectorAll('.operations-panel').forEach(panel => panel.classList.toggle('active', panel.id === `operations-${target}-panel`)); +} + +function setupOperationsCenter() { + const hour = document.getElementById('operations-auto-hour'); + if (hour && !hour.options.length) hour.innerHTML = Array.from({ length: 24 }, (_, index) => ``).join(''); + document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', () => setOperationsTab(button.dataset.operationsTab))); + document.getElementById('operations-refresh-btn')?.addEventListener('click', loadOperationsCenter); + document.getElementById('operations-extension-refresh')?.addEventListener('click', loadOperationsExtensions); + + document.getElementById('operations-backup-create')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; const old = button.innerHTML; button.textContent = 'Backup wird erstellt und geprüft …'; + try { + const result = await api('POST', '/api/operations/backups', { + label: document.getElementById('operations-backup-label')?.value || '', + include_uploads: document.getElementById('operations-backup-uploads')?.checked !== false, + include_config: document.getElementById('operations-backup-config')?.checked === true, + }); + toast(`Backup verifiziert: ${result.filename}`); + document.getElementById('operations-backup-label').value = ''; + await loadOperationsBackups(); + } catch (error) { toast(error.message || 'Backup fehlgeschlagen.'); } + finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); } + }); + + document.getElementById('operations-settings-save')?.addEventListener('click', async () => { + try { + operationsState.status.settings = await api('PUT', '/api/operations/settings', { + auto_backup_enabled: document.getElementById('operations-auto-enabled')?.checked === true, + auto_backup_hour: Number(document.getElementById('operations-auto-hour')?.value || 3), + auto_backup_retention: Number(document.getElementById('operations-auto-retention')?.value || 7), + include_uploads_automatic: document.getElementById('operations-auto-uploads')?.checked !== false, + }); + renderOperationsStatus(); toast('Backup-Zeitplan gespeichert.'); + } catch (error) { toast(error.message); } + }); + + const restoreInput = document.getElementById('operations-restore-input'); + restoreInput?.addEventListener('change', () => { + operationsState.restoreFile = restoreInput.files?.[0] || null; + document.getElementById('operations-restore-file').textContent = operationsState.restoreFile ? `${operationsState.restoreFile.name} · ${operationFormatBytes(operationsState.restoreFile.size)}` : 'Keine Datei ausgewählt.'; + document.getElementById('operations-restore-stage').disabled = !operationsState.restoreFile; + }); + document.getElementById('operations-restore-stage')?.addEventListener('click', async event => { + if (!operationsState.restoreFile || !confirm('Backup vollständig prüfen und Wiederherstellung vorbereiten? Es wird automatisch ein Sicherheitsbackup erstellt.')) return; + const button = event.currentTarget; button.disabled = true; button.textContent = 'Backup wird geprüft …'; + try { const result = await operationsUploadRequest('/api/operations/restore/stage', 'backup', operationsState.restoreFile); toast(result.message); operationsState.restoreFile = null; restoreInput.value = ''; await loadOperationsCenter(); } + catch (error) { toast(error.message); } + finally { button.disabled = !operationsState.restoreFile; button.textContent = 'Wiederherstellung prüfen und vorbereiten'; } + }); + + document.getElementById('operations-update-url-save')?.addEventListener('click', async () => { + try { operationsState.status.settings = await api('PUT', '/api/operations/settings', { update_manifest_url: document.getElementById('operations-update-url')?.value || '' }); renderOperationsStatus(); toast('Update-Manifest-URL gespeichert.'); } + catch (error) { toast(error.message); } + }); + document.getElementById('operations-update-check')?.addEventListener('click', async event => { + const resultBox = document.getElementById('operations-update-check-result'); const button = event.currentTarget; button.disabled = true; if (resultBox) resultBox.textContent = 'Update wird geprüft …'; + try { + const result = await api('POST', '/api/operations/updates/check'); + if (resultBox) resultBox.innerHTML = result.configured ? `${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : 'Vendoo ist aktuell'}Installiert: ${escapeHtml(result.current_version)}${result.latest_version ? ` · Verfügbar: ${escapeHtml(result.latest_version)}` : ''}` : `Keine Online-Prüfung konfiguriert${escapeHtml(result.message || '')}`; + } catch (error) { if (resultBox) resultBox.innerHTML = `Prüfung fehlgeschlagen${escapeHtml(error.message)}`; } + finally { button.disabled = false; } + }); + + const updateInput = document.getElementById('operations-update-input'); + updateInput?.addEventListener('change', () => { + operationsState.updateFile = updateInput.files?.[0] || null; + document.getElementById('operations-update-file').textContent = operationsState.updateFile ? `${operationsState.updateFile.name} · ${operationFormatBytes(operationsState.updateFile.size)}` : 'Keine Datei ausgewählt.'; + document.getElementById('operations-update-stage').disabled = !operationsState.updateFile; + }); + document.getElementById('operations-update-stage')?.addEventListener('click', async event => { + if (!operationsState.updateFile || !confirm('Update prüfen, Sicherheitsbackup erzeugen und für den nächsten Setup-Lauf vorbereiten?')) return; + const button = event.currentTarget; button.disabled = true; button.textContent = 'Update wird geprüft …'; + try { const result = await operationsUploadRequest('/api/operations/updates/stage', 'update', operationsState.updateFile); toast(result.message); operationsState.updateFile = null; updateInput.value = ''; await loadOperationsCenter(); } + catch (error) { toast(error.message); } + finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; } + }); +} + +// --- Admin --- +function setupAdmin() { + // Admin tab switching + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', () => { + const target = tab.dataset.adminTab; + switchAdminTab(target); + if (target === 'users') loadAdminUsers(); + if (target === 'roles') loadRoleMatrix(); + if (target === 'sessions') loadAdminSessions(); + if (target === 'smtp') loadSmtpSettings(); + if (target === 'system') { loadSystemInfo(); loadOperationsCenter(); } + if (target === 'security') loadSecurityCenter(); + if (target === 'audit') loadAdminAudit(); + }); + }); + + document.getElementById('invite-user-btn')?.addEventListener('click', () => { + document.getElementById('invite-email').value = ''; + document.getElementById('invite-name').value = ''; + document.getElementById('invite-role').value = 'editor'; + document.getElementById('invite-result-wrap').classList.add('hidden'); + document.getElementById('invite-result-password').value = ''; + const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]'); + if (mlRadio) mlRadio.checked = true; + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + document.getElementById('invite-modal').classList.remove('hidden'); + }); + + document.getElementById('invite-copy-pw')?.addEventListener('click', () => { + const pw = document.getElementById('invite-result-password').value; + if (pw) { navigator.clipboard.writeText(pw); toast('Passwort kopiert!'); } + }); + + document.getElementById('invite-send-btn')?.addEventListener('click', sendInvite); + document.getElementById('edit-user-save-btn')?.addEventListener('click', saveUserEdit); + + // SMTP + document.getElementById('smtp-save-btn')?.addEventListener('click', saveSmtpSettings); + document.getElementById('smtp-test-btn')?.addEventListener('click', testSmtpConnection); + document.getElementById('smtp-pass-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('smtp-pass'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('smtp-pass-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // Edit user password toggle + document.getElementById('edit-user-pw-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('edit-user-password'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('edit-user-pw-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // System tab + document.getElementById('system-cleanup-btn')?.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/admin/sessions/cleanup'); + toast(res.message || 'Sessions bereinigt'); + loadSystemInfo(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('system-diagnostics-btn')?.addEventListener('click', runSystemDiagnostics); + document.getElementById('system-diagnostics-copy')?.addEventListener('click', copySystemDiagnostics); + document.getElementById('security-refresh-btn')?.addEventListener('click', loadSecurityCenter); + document.getElementById('audit-filter-btn')?.addEventListener('click', () => { auditState.page = 1; loadAdminAudit(); }); + document.getElementById('audit-search')?.addEventListener('keydown', event => { if (event.key === 'Enter') { auditState.page = 1; loadAdminAudit(); } }); + document.getElementById('audit-prev')?.addEventListener('click', () => { if (auditState.page > 1) { auditState.page -= 1; loadAdminAudit(); } }); + document.getElementById('audit-next')?.addEventListener('click', () => { if (auditState.page < auditState.pages) { auditState.page += 1; loadAdminAudit(); } }); + document.getElementById('audit-export-btn')?.addEventListener('click', exportAdminAudit); + + setupOperationsCenter(); +} + +function switchAdminTab(target) { + document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.adminTab === target)); + document.querySelectorAll('.admin-panel').forEach(p => p.classList.remove('active')); + document.getElementById(`admin-${target}-panel`)?.classList.add('active'); +} + +function timeAgo(dateStr) { + if (!dateStr) return 'Nie'; + const diff = Date.now() - new Date(dateStr + 'Z').getTime(); + const min = Math.floor(diff / 60000); + if (min < 1) return 'Gerade eben'; + if (min < 60) return `Vor ${min}m`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `Vor ${hrs}h`; + const days = Math.floor(hrs / 24); + return `Vor ${days}d`; +} + +function getInitials(name, email) { + const src = name || email || '?'; + return src.split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); +} + +const ROLE_LABELS = { admin: 'Administrator', manager: 'Manager', editor: 'Editor', publisher: 'Publisher', viewer: 'Leser' }; +let adminUsersCache = []; + +async function loadRoleMatrix() { + const cards = document.getElementById('role-cards'); + const matrix = document.getElementById('role-permission-matrix'); + if (!cards || !matrix) return; + try { + const data = await api('GET', '/api/security/roles'); + cards.innerHTML = data.roles.map(role => `
                          ${escapeHtml(role.label)}${role.permissions.length} Rechte

                          ${escapeHtml(role.description)}

                          `).join(''); + const groups = [...new Set(data.permissions.map(permission => permission.group))]; + matrix.innerHTML = groups.map(group => { + const permissions = data.permissions.filter(permission => permission.group === group); + return `

                          ${escapeHtml(group)}

                          Berechtigung${data.roles.map(role => `${escapeHtml(role.label)}`).join('')}
                          ${permissions.map(permission => `
                          ${escapeHtml(permission.label)}${data.roles.map(role => `${role.permissions.includes(permission.key) ? '✓' : '—'}`).join('')}
                          `).join('')}
                          `; + }).join(''); + } catch (error) { + cards.innerHTML = `
                          ${escapeHtml(error.message)}
                          `; + matrix.innerHTML = ''; + } +} + +async function forceReleaseSecurityLock(type, id) { + if (!confirm('Bearbeitungssperre wirklich administrativ aufheben?')) return; + try { await api('DELETE', `/api/security/locks/${encodeURIComponent(type)}/${encodeURIComponent(id)}`); toast('Sperre aufgehoben'); loadSecurityCenter(); } + catch (error) { toast(error.message); } +} + +async function loadSecurityCenter() { + const summary = document.getElementById('security-status-summary'); + if (!summary) return; + summary.innerHTML = '
                          Sicherheitsstatus wird geladen …
                          '; + try { + const status = await api('GET', '/api/security/status'); + const secure = !status.remote_binding || status.secure_public_url; + summary.innerHTML = `
                          ${secure && !status.warnings.length ? 'Sicherheitsbasis aktiv' : 'Konfiguration braucht Aufmerksamkeit'}

                          Vendoo ${escapeHtml(status.version)} · Bind ${escapeHtml(status.bind_host)}:${status.port} · ${status.trust_proxy ? 'Reverse-Proxy vertraut' : 'Direkte Verbindung'}

                          `; + const warnings = document.getElementById('security-warning-list'); + warnings.innerHTML = status.warnings.length ? status.warnings.map(message => `

                          ${escapeHtml(message)}

                          `).join('') : '
                          Keine kritischen Konfigurationswarnungen erkannt.
                          '; + document.getElementById('security-network-info').innerHTML = `
                          Bind-Adresse
                          ${escapeHtml(status.bind_host)}
                          Öffentliche URL
                          ${status.public_base_url ? `${escapeHtml(status.public_base_url)}` : 'Nicht gesetzt'}
                          HTTPS
                          ${status.secure_public_url ? 'Aktiv' : 'Nicht erkannt'}
                          Erlaubte Hosts
                          ${status.allowed_hosts.length ? status.allowed_hosts.map(escapeHtml).join(', ') : 'Nur lokale/private Hosts'}
                          Erlaubte Origins
                          ${status.allowed_origins.length ? status.allowed_origins.map(escapeHtml).join(', ') : 'Nur Same-Origin und Extensions'}
                          `; + document.getElementById('security-session-info').innerHTML = `
                          Absolute Sessiondauer
                          ${status.session_days} Tage
                          Inaktivitätslimit
                          ${status.session_idle_minutes} Minuten
                          Rate-Limit erlaubt
                          ${status.rate_limits.allowed}
                          Rate-Limit blockiert
                          ${status.rate_limits.blocked}
                          Aktive Sperren
                          ${status.active_locks.length}
                          `; + const locks = document.getElementById('security-lock-list'); + locks.innerHTML = status.active_locks.length ? status.active_locks.map(lock => `
                          ${escapeHtml(lock.resource_type)} #${escapeHtml(lock.resource_id)}

                          ${escapeHtml(lock.user_name || lock.user_email)} · bis ${new Date(lock.expires_at).toLocaleTimeString('de-DE')}

                          `).join('') : '
                          Keine aktiven Bearbeitungssperren.
                          '; + locks.querySelectorAll('[data-force-lock-type]').forEach(button => button.addEventListener('click', () => forceReleaseSecurityLock(button.dataset.forceLockType, button.dataset.forceLockId))); + globalThis.VendooIcons?.render?.(); + } catch (error) { summary.innerHTML = `
                          ${escapeHtml(error.message)}
                          `; } +} + +const auditState = { page: 1, pages: 1, total: 0 }; + +function buildAuditQuery() { + const size = Number(document.getElementById('audit-page-size')?.value || 100); + const params = new URLSearchParams({ limit: String(size), offset: String((auditState.page - 1) * size) }); + const values = { + q: document.getElementById('audit-search')?.value.trim(), + action: document.getElementById('audit-action-filter')?.value.trim(), + from: document.getElementById('audit-from')?.value, + to: document.getElementById('audit-to')?.value ? `${document.getElementById('audit-to').value} 23:59:59` : '', + }; + for (const [key, value] of Object.entries(values)) if (value) params.set(key, value); + return params; +} + +function exportAdminAudit() { + const params = buildAuditQuery(); + params.delete('limit'); params.delete('offset'); + window.location.href = `/api/admin/audit/export.csv?${params.toString()}`; +} + +async function loadAdminUsers() { + try { + const users = await api('GET', '/api/admin/users'); + adminUsersCache = Array.isArray(users) ? users : []; + const container = document.getElementById('users-list'); + if (!container) return; + if (!adminUsersCache.length) { container.innerHTML = '
                          Keine User vorhanden
                          '; return; } + + container.innerHTML = adminUsersCache.map(u => { + const initials = getInitials(u.name, u.email); + const isOnline = u.last_active_at && (Date.now() - new Date(`${u.last_active_at}Z`).getTime()) < 5 * 60 * 1000; + const dotClass = !u.active ? 'inactive' : isOnline ? 'online' : 'offline'; + const lockedBadge = u.locked_until && Date.parse(u.locked_until) > Date.now() ? 'Vorübergehend gesperrt' : ''; + const pwBadge = u.has_password + ? 'Passwort gesetzt' + : 'Kein Passwort'; + return `
                          +
                          ${escapeHtml(initials)}
                          +
                          +
                          ${escapeHtml(u.name || u.email)} ${escapeHtml(ROLE_LABELS[u.role] || u.role)} ${pwBadge} ${lockedBadge}
                          + +
                          ${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(`${u.created_at}Z`).toLocaleDateString('de-DE')}
                          +
                          +
                          + + + ${u.id !== currentUser?.id ? `` : ''} +
                          +
                          `; + }).join(''); + + container.querySelectorAll('[data-user-edit]').forEach(button => button.addEventListener('click', () => openEditUser(Number(button.dataset.userEdit)))); + container.querySelectorAll('[data-user-resend]').forEach(button => button.addEventListener('click', () => resendInvite(Number(button.dataset.userResend)))); + container.querySelectorAll('[data-user-delete]').forEach(button => button.addEventListener('click', () => { + const user = adminUsersCache.find(entry => Number(entry.id) === Number(button.dataset.userDelete)); + if (user) confirmDeleteUser(user.id, user.email); + })); + globalThis.VendooIcons?.render?.(container); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function sendInvite() { + const email = document.getElementById('invite-email').value.trim(); + const name = document.getElementById('invite-name').value.trim(); + const role = document.getElementById('invite-role').value; + const method = document.querySelector('input[name="invite-method"]:checked')?.value || 'magic_link'; + if (!email) return toast('E-Mail erforderlich'); + + try { + const result = await api('POST', '/api/admin/users/invite', { email, name, role, method }); + + if (method === 'auto_password' && result.password) { + // Show generated password + const resultWrap = document.getElementById('invite-result-wrap'); + const pwInput = document.getElementById('invite-result-password'); + pwInput.value = result.password; + resultWrap.classList.remove('hidden'); + document.getElementById('invite-send-btn').textContent = 'Fertig'; + document.getElementById('invite-send-btn').onclick = () => { + document.getElementById('invite-modal').classList.add('hidden'); + resultWrap.classList.add('hidden'); + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + }; + toast(result.consoleFallback ? 'Account erstellt — Zugangsdaten in Konsole' : 'Account erstellt & Zugangsdaten gesendet'); + } else { + document.getElementById('invite-modal').classList.add('hidden'); + if (result.consoleFallback) { + toast('Einladung erstellt — Link in Server-Konsole'); + } else { + toast('Einladung gesendet an ' + email); + } + } + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function resendInvite(userId) { + try { + const result = await api('POST', '/api/admin/users/resend-invite', { user_id: userId }); + if (result.consoleFallback) { + toast('Link in Server-Konsole ausgegeben'); + } else { + toast('Einladung erneut gesendet'); + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function openEditUser(userId) { + try { + let user = adminUsersCache.find(entry => Number(entry.id) === Number(userId)); + if (!user) { + const users = await api('GET', '/api/admin/users'); + adminUsersCache = Array.isArray(users) ? users : []; + user = adminUsersCache.find(entry => Number(entry.id) === Number(userId)); + } + if (!user) throw new Error('Benutzer wurde nicht gefunden.'); + document.getElementById('edit-user-id').value = user.id; + const emailField = document.getElementById('edit-user-email'); + if (emailField) emailField.value = user.email || ''; + document.getElementById('edit-user-name').value = user.name || ''; + document.getElementById('edit-user-role').value = user.role; + document.getElementById('edit-user-active').value = user.active ? '1' : '0'; + document.getElementById('edit-user-password').value = ''; + document.getElementById('edit-user-password').type = 'password'; + document.getElementById('edit-user-pw-toggle').textContent = 'Zeigen'; + + // Password status + const pwStatus = document.getElementById('edit-user-pw-status'); + if (user.has_password) { + pwStatus.textContent = 'Passwort gesetzt'; + pwStatus.className = 'pw-status-badge has-pw'; + } else { + pwStatus.textContent = 'Kein Passwort'; + pwStatus.className = 'pw-status-badge no-pw'; + } + + // Login history + const historyEl = document.getElementById('edit-user-login-history'); + historyEl.classList.add('hidden'); + historyEl.innerHTML = ''; + + const showHistBtn = document.getElementById('edit-user-show-history'); + showHistBtn.onclick = async () => { + if (!historyEl.classList.contains('hidden')) { + historyEl.classList.add('hidden'); + showHistBtn.textContent = 'Login-Verlauf anzeigen'; + return; + } + try { + const history = await api('GET', `/api/admin/users/${userId}/login-history`); + if (!history.length) { + historyEl.innerHTML = '
                          Keine Login-Einträge
                          '; + } else { + historyEl.innerHTML = history.map(h => { + const dt = new Date(h.created_at + 'Z').toLocaleString('de-DE'); + const statusClass = h.success ? 'success' : 'failed'; + const statusText = h.success ? 'Erfolgreich' : 'Fehlgeschlagen'; + return ``; + }).join(''); + } + historyEl.classList.remove('hidden'); + showHistBtn.textContent = 'Verlauf ausblenden'; + } catch (err) { toast('Fehler: ' + err.message); } + }; + + document.getElementById('edit-user-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveUserEdit() { + const id = Number(document.getElementById('edit-user-id').value); + const name = document.getElementById('edit-user-name').value.trim(); + const role = document.getElementById('edit-user-role').value; + const active = Number(document.getElementById('edit-user-active').value); + const password = document.getElementById('edit-user-password').value; + const button = document.getElementById('edit-user-save-btn'); + + if (!Number.isInteger(id) || id <= 0) return toast('Ungültiger Benutzer. Bitte die Liste neu laden.'); + if (!name) return toast('Bitte einen Namen eintragen.'); + if (password && (password.length < 10 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password) || !/[^A-Za-z0-9]/.test(password))) { + return toast('Passwort braucht mindestens 10 Zeichen, Groß-/Kleinbuchstaben, Zahl und Sonderzeichen'); + } + + const oldLabel = button?.textContent || 'Speichern'; + if (button) { button.disabled = true; button.textContent = 'Wird gespeichert …'; } + try { + const updated = await api('PUT', `/api/admin/users/${id}`, { name, role, active }); + if (password) await api('POST', `/api/admin/users/${id}/reset-password`, { password }); + adminUsersCache = adminUsersCache.map(entry => Number(entry.id) === id ? { ...entry, ...updated, has_password: password ? 1 : entry.has_password } : entry); + toast(password ? 'Benutzer aktualisiert und Passwort zurückgesetzt.' : 'Benutzer aktualisiert.'); + document.getElementById('edit-user-modal').classList.add('hidden'); + await loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } + finally { if (button) { button.disabled = false; button.textContent = oldLabel; } } +} + +async function confirmDeleteUser(userId, email) { + if (!confirm(`User "${email}" wirklich löschen? Alle Sessions werden beendet.`)) return; + try { + await api('DELETE', `/api/admin/users/${userId}`); + toast('User gelöscht'); + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminSessions() { + try { + const sessions = await api('GET', '/api/admin/sessions'); + const container = document.getElementById('sessions-list'); + if (!sessions.length) { container.innerHTML = '
                          Keine aktiven Sessions
                          '; return; } + + container.innerHTML = sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                          +
                          + ${s.name || s.email} ${s.role} +
                          ${ua}
                          +
                          IP: ${s.ip || 'unbekannt'}
                          +
                          +
                          + ${timeAgo(s.created_at)}
                          + Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')} +
                          + +
                          `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function killSession(sessionId) { + try { + await api('DELETE', `/api/admin/sessions/${sessionId}`); + toast('Session beendet'); + loadAdminSessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminAudit() { + try { + const params = buildAuditQuery(); + const result = await api('GET', `/api/admin/audit?${params.toString()}`); + const logs = result.rows || []; + const size = Number(result.limit || 100); + auditState.total = Number(result.total || 0); + auditState.pages = Math.max(1, Math.ceil(auditState.total / size)); + if (auditState.page > auditState.pages) auditState.page = auditState.pages; + const container = document.getElementById('audit-list'); + document.getElementById('audit-summary').textContent = `${auditState.total.toLocaleString('de-DE')} protokollierte Ereignisse`; + document.getElementById('audit-page-info').textContent = `Seite ${auditState.page} von ${auditState.pages}`; + document.getElementById('audit-prev').disabled = auditState.page <= 1; + document.getElementById('audit-next').disabled = auditState.page >= auditState.pages; + if (!logs.length) { container.innerHTML = '
                          Keine passenden Audit-Einträge
                          '; return; } + container.innerHTML = logs.map(l => { + const actionParts = (l.action || '').split('.'); + const category = actionParts[0] || 'other'; + return `
                          ${new Date(String(l.created_at).replace(' ', 'T') + 'Z').toLocaleString('de-DE')}
                          ${escapeHtml(l.user_email || 'System')}${escapeHtml(l.ip || '')}
                          ${escapeHtml(l.action)}${l.target_type ? `${escapeHtml(l.target_type)} ${escapeHtml(l.target_id || '')}` : ''}${l.details ? `

                          ${escapeHtml(l.details)}

                          ` : ''}
                          `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +// --- SMTP Settings --- +async function loadSmtpSettings() { + try { + const smtp = await api('GET', '/api/admin/smtp'); + document.getElementById('smtp-host').value = smtp.smtp_host || ''; + document.getElementById('smtp-port').value = smtp.smtp_port || '587'; + document.getElementById('smtp-user').value = smtp.smtp_user || ''; + document.getElementById('smtp-pass').value = ''; + document.getElementById('smtp-pass').placeholder = smtp.smtp_pass ? 'Gesetzt (leer = beibehalten)' : 'Passwort'; + document.getElementById('smtp-from').value = smtp.smtp_from || ''; + + const dot = document.getElementById('smtp-status-dot'); + const text = document.getElementById('smtp-status-text'); + if (smtp.configured) { + dot.className = 'smtp-status-dot configured'; + text.textContent = `SMTP konfiguriert (Quelle: ${smtp.source === 'db' ? 'Datenbank' : smtp.source === 'env' ? '.env Datei' : 'Nicht konfiguriert'})`; + } else { + dot.className = 'smtp-status-dot not-configured'; + text.textContent = 'SMTP nicht konfiguriert — E-Mails werden in der Konsole ausgegeben'; + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveSmtpSettings() { + const data = { + smtp_host: document.getElementById('smtp-host').value.trim(), + smtp_port: document.getElementById('smtp-port').value.trim(), + smtp_user: document.getElementById('smtp-user').value.trim(), + smtp_from: document.getElementById('smtp-from').value.trim(), + }; + const pass = document.getElementById('smtp-pass').value; + if (pass) data.smtp_pass = pass; + + try { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = true; btn.textContent = 'Speichere...'; + await api('PUT', '/api/admin/smtp', data); + toast('SMTP-Einstellungen gespeichert'); + loadSmtpSettings(); + } catch (err) { toast('Fehler: ' + err.message); } + finally { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = false; btn.textContent = 'SMTP speichern'; + } +} + +async function testSmtpConnection() { + const btn = document.getElementById('smtp-test-btn'); + btn.disabled = true; btn.textContent = 'Sende Testmail...'; + try { + const res = await api('POST', '/api/admin/smtp/test'); + toast(res.message || 'Testmail gesendet!'); + } catch (err) { toast('Fehler: ' + err.message); } + finally { btn.disabled = false; btn.textContent = 'Verbindung testen'; } +} + + +// --- Batch Image Factory --- +let imageFactoryPollTimer = null; +let imageFactoryEditingProfileId = null; + +function imageFactoryStatusMeta(status) { + return ({ + queued: ['Wartend', 'queued'], running: ['Läuft', 'running'], paused: ['Pausiert', 'paused'], + completed: ['Fertig', 'completed'], partial: ['Teilweise fertig', 'partial'], failed: ['Fehlgeschlagen', 'failed'], + cancelled: ['Abgebrochen', 'cancelled'], + })[status] || [status || 'Unbekannt', 'queued']; +} + +function updateImageFactorySelection() { + const sources = state.imageFactorySelectedSources.size; + const profiles = state.imageFactorySelectedProfiles.size; + const outputs = sources * profiles; + document.getElementById('image-factory-selection-count')?.replaceChildren(document.createTextNode(`${sources} Bilder · ${profiles} Profile`)); + document.getElementById('image-factory-output-estimate')?.replaceChildren(document.createTextNode(outputs ? `${outputs} neue Bilddateien werden erzeugt` : 'Noch keine Ausgabe geplant')); + const submit = document.getElementById('image-factory-submit'); + if (submit) submit.disabled = !sources || !profiles; +} + +function renderImageFactorySources() { + const container = document.getElementById('image-factory-source-grid'); + if (!container) return; + if (!state.imageFactorySources.length) { + container.innerHTML = '
                          Keine passenden Bilder gefunden. Passe Suche oder Quellenfilter an.
                          '; + return; + } + container.innerHTML = state.imageFactorySources.map(item => { + const selected = state.imageFactorySelectedSources.has(item.image_path); + const title = item.title || item.prompt || item.image_path.split('/').pop(); + return ``; + }).join(''); + container.querySelectorAll('[data-image-factory-source]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.imageFactorySelectedSources.add(input.dataset.imageFactorySource); + else state.imageFactorySelectedSources.delete(input.dataset.imageFactorySource); + input.closest('.image-factory-source-card')?.classList.toggle('is-selected', input.checked); + updateImageFactorySelection(); + })); +} + +function profileDimensionLabel(profile) { + const resize = profile.operations?.resize || {}; + const dimensions = resize.width || resize.height ? `${resize.width || 'auto'} × ${resize.height || 'auto'}` : 'Originalgröße'; + return `${dimensions} · ${(profile.operations?.format || 'jpeg').toUpperCase()} · Q${profile.operations?.quality || 90}`; +} + +function renderImageFactoryProfiles() { + const container = document.getElementById('image-factory-profile-grid'); + if (!container) return; + if (!state.imageFactoryProfiles.length) { + container.innerHTML = '
                          Keine Produktionsprofile vorhanden.
                          '; + return; + } + container.innerHTML = state.imageFactoryProfiles.map(profile => { + const selected = state.imageFactorySelectedProfiles.has(Number(profile.id)); + return `
                          + +
                          ${profile.is_builtin ? 'P' : 'E'}
                          +
                          ${escapeHtml(profile.name)}

                          ${escapeHtml(profile.description || 'Eigenes Produktionsprofil')}

                          ${escapeHtml(profileDimensionLabel(profile))}
                          + ${profile.is_builtin ? 'Standard' : `
                          `} +
                          `; + }).join(''); + container.querySelectorAll('[data-image-factory-profile]').forEach(input => input.addEventListener('change', () => { + const id = Number(input.dataset.imageFactoryProfile); + if (input.checked) state.imageFactorySelectedProfiles.add(id); else state.imageFactorySelectedProfiles.delete(id); + input.closest('.image-factory-profile-card')?.classList.toggle('is-selected', input.checked); + updateImageFactorySelection(); + })); + container.querySelectorAll('[data-image-factory-profile-edit]').forEach(button => button.addEventListener('click', () => openImageFactoryProfileModal(Number(button.dataset.imageFactoryProfileEdit)))); + container.querySelectorAll('[data-image-factory-profile-delete]').forEach(button => button.addEventListener('click', async () => { + const id = Number(button.dataset.imageFactoryProfileDelete); + if (!confirm('Dieses eigene Produktionsprofil löschen? Bestehende Aufträge und Bilder bleiben erhalten.')) return; + try { await api('DELETE', `/api/image-batch/profiles/${id}`); state.imageFactorySelectedProfiles.delete(id); await loadImageFactoryProfiles(); toast('Produktionsprofil gelöscht.'); } + catch (error) { toast(`Profil konnte nicht gelöscht werden: ${error.message}`); } + })); +} + +async function loadImageFactorySources({ silent = false } = {}) { + const container = document.getElementById('image-factory-source-grid'); + if (!silent && container) container.innerHTML = '
                          Bilder werden geladen …
                          '; + try { + const params = new URLSearchParams({ page: '1', page_size: '96', source: document.getElementById('image-factory-source-filter')?.value || 'all', sort: 'newest' }); + const search = document.getElementById('image-factory-source-search')?.value.trim(); + if (search) params.set('q', search); + const result = await api('GET', `/api/media-library?${params}`); + state.imageFactorySources = result.items || []; + renderImageFactorySources(); + } catch (error) { + if (container) container.innerHTML = `
                          Bilder konnten nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +async function loadImageFactoryProfiles() { + try { + const result = await api('GET', '/api/image-batch/profiles'); + state.imageFactoryProfiles = result.items || []; + if (!state.imageFactorySelectedProfiles.size && state.imageFactoryProfiles.length) state.imageFactorySelectedProfiles.add(Number(state.imageFactoryProfiles[0].id)); + state.imageFactorySelectedProfiles = new Set([...state.imageFactorySelectedProfiles].filter(id => state.imageFactoryProfiles.some(profile => Number(profile.id) === Number(id)))); + renderImageFactoryProfiles(); + updateImageFactorySelection(); + } catch (error) { + const container = document.getElementById('image-factory-profile-grid'); + if (container) container.innerHTML = `
                          Profile konnten nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +function renderImageFactorySummary() { + const summary = state.imageFactorySummary || {}; + document.querySelectorAll('[data-batch-summary]').forEach(node => { node.textContent = String(summary[node.dataset.batchSummary] || 0); }); +} + +function batchItemStatusLabel(status) { + return ({ queued: 'Wartend', running: 'Wird erzeugt', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' })[status] || status; +} + +function renderImageFactoryJobItems(job) { + if (!job.items) return '
                          Details werden geladen …
                          '; + return `
                          ${job.items.map(item => `
                          + +
                          ${escapeHtml(item.profile_name)}${escapeHtml(item.source_path.split('/').pop())}${item.error_message ? `${escapeHtml(item.error_message)}` : ''}
                          + ${escapeHtml(batchItemStatusLabel(item.status))} + ${['failed','cancelled'].includes(item.status) ? `` : ''} + ${item.output_path ? `Download` : ''} +
                          `).join('')}
                          `; +} + +function renderImageFactoryJobs() { + const container = document.getElementById('image-factory-jobs'); + if (!container) return; + if (!state.imageFactoryJobs.length) { + container.innerHTML = '
                          Noch keine Produktionsaufträge. Wähle Bilder und Profile und starte die erste Produktion.
                          '; + } else { + container.innerHTML = state.imageFactoryJobs.map(job => { + const [label, tone] = imageFactoryStatusMeta(job.status); + const terminal = ['completed','partial','failed','cancelled'].includes(job.status); + const expanded = state.imageFactoryExpanded.has(job.id); + const done = Number(job.completed_items || 0) + Number(job.failed_items || 0) + Number(job.cancelled_items || 0); + const total = Math.max(1, Number(job.total_items || 0)); + const percent = Math.min(100, Math.round(done / total * 100)); + return `
                          + +
                          +
                          + ${['queued','running'].includes(job.status) ? `` : ''} + ${job.status === 'paused' ? `` : ''} + ${['failed','partial','cancelled'].includes(job.status) ? `` : ''} + ${Number(job.completed_items || 0) > 0 ? `Ergebnisse als ZIP` : ''} + ${terminal ? `` : ''} +
                          +
                          ${expanded ? renderImageFactoryJobItems(job) : ''}
                          +
                          `; + }).join(''); + } + container.querySelectorAll('[data-image-factory-job-toggle]').forEach(button => button.addEventListener('click', async () => { + const id = button.dataset.imageFactoryJobToggle; + if (state.imageFactoryExpanded.has(id)) state.imageFactoryExpanded.delete(id); else state.imageFactoryExpanded.add(id); + const index = state.imageFactoryJobs.findIndex(job => job.id === id); + if (index >= 0 && state.imageFactoryExpanded.has(id) && !state.imageFactoryJobs[index].items) { + try { state.imageFactoryJobs[index] = await api('GET', `/api/image-batch/jobs/${encodeURIComponent(id)}`); } catch (error) { toast(error.message); } + } + renderImageFactoryJobs(); + })); + container.querySelectorAll('[data-image-factory-action]').forEach(button => button.addEventListener('click', () => handleImageFactoryJobAction(button.dataset.imageFactoryAction, button.dataset.jobId))); + container.querySelectorAll('[data-image-factory-retry-item]').forEach(button => button.addEventListener('click', async () => { + try { await api('POST', `/api/image-batch/jobs/${encodeURIComponent(button.dataset.jobId)}/items/${encodeURIComponent(button.dataset.imageFactoryRetryItem)}/retry`); toast('Bildposition wurde erneut eingereiht.'); await loadImageFactoryJobs({ silent: true }); } + catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); } + })); + const pagination = state.imageFactoryJobPagination || {}; + document.getElementById('image-factory-job-page-info')?.replaceChildren(document.createTextNode(`Seite ${pagination.page || 1} von ${pagination.total_pages || 1}`)); + const prev = document.getElementById('image-factory-job-prev'); const next = document.getElementById('image-factory-job-next'); + if (prev) prev.disabled = Number(pagination.page || 1) <= 1; + if (next) next.disabled = Number(pagination.page || 1) >= Number(pagination.total_pages || 1); +} + +async function loadImageFactoryJobs({ silent = false } = {}) { + const container = document.getElementById('image-factory-jobs'); + if (!silent && container && !state.imageFactoryJobs.length) container.innerHTML = '
                          Produktionsaufträge werden geladen …
                          '; + try { + const result = await api('GET', `/api/image-batch/jobs?page=${state.imageFactoryJobPage}&page_size=${state.imageFactoryJobPageSize}`); + const previous = new Map(state.imageFactoryJobs.map(job => [job.id, job])); + state.imageFactoryJobs = (result.items || []).map(job => state.imageFactoryExpanded.has(job.id) && previous.get(job.id)?.items ? { ...job, items: previous.get(job.id).items } : job); + const expandedJobs = state.imageFactoryJobs.filter(job => state.imageFactoryExpanded.has(job.id)); + if (expandedJobs.length) { + const details = await Promise.all(expandedJobs.map(job => api('GET', `/api/image-batch/jobs/${encodeURIComponent(job.id)}`).catch(() => null))); + const detailMap = new Map(details.filter(Boolean).map(job => [job.id, job])); + state.imageFactoryJobs = state.imageFactoryJobs.map(job => detailMap.get(job.id) || job); + } + state.imageFactoryJobPagination = result.pagination || { page: 1, total_pages: 1, total: 0 }; + state.imageFactoryJobPage = Number(state.imageFactoryJobPagination.page || 1); + state.imageFactorySummary = result.summary || null; + renderImageFactorySummary(); + renderImageFactoryJobs(); + const active = state.imageFactoryJobs.some(job => ['queued','running'].includes(job.status)); + if (imageFactoryPollTimer) clearTimeout(imageFactoryPollTimer); + if (active && document.getElementById('image-factory')?.classList.contains('active')) imageFactoryPollTimer = setTimeout(() => loadImageFactoryJobs({ silent: true }), 1600); + } catch (error) { + if (container) container.innerHTML = `
                          Aufträge konnten nicht geladen werden: ${escapeHtml(error.message)}
                          `; + } +} + +async function loadImageFactory() { + await Promise.all([loadImageFactoryProfiles(), loadImageFactorySources(), loadImageFactoryJobs()]); +} + +async function submitImageFactoryJob() { + const button = document.getElementById('image-factory-submit'); + const sourcePaths = [...state.imageFactorySelectedSources]; + const profileIds = [...state.imageFactorySelectedProfiles]; + if (!sourcePaths.length || !profileIds.length) return toast('Bitte mindestens ein Bild und ein Produktionsprofil auswählen.'); + const original = button?.textContent || 'Bildproduktion starten'; + if (button) { button.disabled = true; button.textContent = 'Auftrag wird angelegt …'; } + const watermarkEnabled = document.getElementById('image-factory-watermark')?.checked; + const overrides = { + remove_background: document.getElementById('image-factory-remove-bg')?.checked || false, + background_sensitivity: Number(document.getElementById('image-factory-bg-sensitivity')?.value || 45), + watermark: watermarkEnabled ? { + enabled: true, + text: document.getElementById('image-factory-watermark-text')?.value || 'Vendoo', + position: document.getElementById('image-factory-watermark-position')?.value || 'bottom-right', + opacity: Number(document.getElementById('image-factory-watermark-opacity')?.value || 40), + } : { enabled: false }, + }; + try { + const job = await api('POST', '/api/image-batch/jobs', { name: document.getElementById('image-factory-name')?.value.trim(), source_paths: sourcePaths, profile_ids: profileIds, overrides }); + toast(`${job.total_items} Bildausgaben wurden in die Produktion eingereiht.`); + state.imageFactoryJobPage = 1; + state.imageFactoryExpanded.add(job.id); + await loadImageFactoryJobs({ silent: true }); + document.getElementById('image-factory-jobs')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch (error) { toast(`Bildproduktion konnte nicht gestartet werden: ${error.message}`); } + finally { if (button) { button.disabled = false; button.textContent = original; } updateImageFactorySelection(); } +} + +async function handleImageFactoryJobAction(action, jobId) { + if (action === 'delete' && !confirm('Diesen abgeschlossenen Auftrag aus dem Job-Center löschen? Die erzeugten Bilder bleiben erhalten.')) return; + try { + if (action === 'delete') await api('DELETE', `/api/image-batch/jobs/${encodeURIComponent(jobId)}`); + else await api('POST', `/api/image-batch/jobs/${encodeURIComponent(jobId)}/${action}`); + toast(({ pause: 'Auftrag wird pausiert.', resume: 'Auftrag wird fortgesetzt.', cancel: 'Auftrag wird abgebrochen.', retry: 'Fehler wurden erneut eingereiht.', delete: 'Auftrag gelöscht.' })[action] || 'Auftrag aktualisiert.'); + await loadImageFactoryJobs({ silent: true }); + } catch (error) { toast(`Aktion fehlgeschlagen: ${error.message}`); } +} + +function openImageFactoryProfileModal(profileId = null) { + imageFactoryEditingProfileId = profileId; + const profile = state.imageFactoryProfiles.find(item => Number(item.id) === Number(profileId)); + const operations = profile?.operations || {}; + const resize = operations.resize || {}; + document.getElementById('image-factory-profile-title').textContent = profile ? 'Produktionsprofil bearbeiten' : 'Produktionsprofil anlegen'; + document.getElementById('image-factory-profile-name').value = profile?.name || ''; + document.getElementById('image-factory-profile-description').value = profile?.description || ''; + document.getElementById('image-factory-profile-width').value = resize.width || 1600; + document.getElementById('image-factory-profile-height').value = resize.height || 1600; + document.getElementById('image-factory-profile-fit').value = resize.fit || 'contain'; + document.getElementById('image-factory-profile-background').value = resize.background || '#ffffff'; + document.getElementById('image-factory-profile-format').value = operations.format || 'jpeg'; + document.getElementById('image-factory-profile-quality').value = operations.quality || 90; + document.getElementById('image-factory-profile-pattern').value = profile?.filename_pattern || '{name}-{profile}-{index}'; + const modal = document.getElementById('image-factory-profile-modal'); + modal?.classList.remove('hidden'); modal?.setAttribute('aria-hidden', 'false'); +} + +function closeImageFactoryProfileModal() { + const modal = document.getElementById('image-factory-profile-modal'); + modal?.classList.add('hidden'); modal?.setAttribute('aria-hidden', 'true'); imageFactoryEditingProfileId = null; +} + +async function saveImageFactoryProfile() { + const data = { + name: document.getElementById('image-factory-profile-name')?.value.trim(), + description: document.getElementById('image-factory-profile-description')?.value.trim(), + filename_pattern: document.getElementById('image-factory-profile-pattern')?.value.trim(), + operations: { + resize: { + width: Number(document.getElementById('image-factory-profile-width')?.value || 0), + height: Number(document.getElementById('image-factory-profile-height')?.value || 0), + fit: document.getElementById('image-factory-profile-fit')?.value || 'contain', + background: document.getElementById('image-factory-profile-background')?.value || '#ffffff', + }, + format: document.getElementById('image-factory-profile-format')?.value || 'jpeg', + quality: Number(document.getElementById('image-factory-profile-quality')?.value || 90), + sharpness: 12, + }, + }; + if (!data.name) return toast('Bitte einen Namen für das Produktionsprofil eingeben.'); + const button = document.getElementById('image-factory-profile-save'); + if (button) button.disabled = true; + try { + const profile = imageFactoryEditingProfileId + ? await api('PATCH', `/api/image-batch/profiles/${imageFactoryEditingProfileId}`, data) + : await api('POST', '/api/image-batch/profiles', data); + state.imageFactorySelectedProfiles.add(Number(profile.id)); + closeImageFactoryProfileModal(); await loadImageFactoryProfiles(); toast('Produktionsprofil gespeichert.'); + } catch (error) { toast(`Profil konnte nicht gespeichert werden: ${error.message}`); } + finally { if (button) button.disabled = false; } +} + +function setupImageFactory() { + document.getElementById('image-factory-refresh')?.addEventListener('click', loadImageFactory); + document.getElementById('image-factory-jobs-refresh')?.addEventListener('click', () => loadImageFactoryJobs()); + document.getElementById('image-factory-start')?.addEventListener('click', () => document.getElementById('image-factory-source-grid')?.scrollIntoView({ behavior: 'smooth', block: 'center' })); + document.getElementById('image-factory-submit')?.addEventListener('click', submitImageFactoryJob); + document.getElementById('image-factory-source-filter')?.addEventListener('change', () => loadImageFactorySources()); + document.getElementById('image-factory-source-search')?.addEventListener('input', debounce(() => loadImageFactorySources({ silent: true }), 350)); + document.getElementById('image-factory-select-visible')?.addEventListener('click', () => { + const visible = state.imageFactorySources.map(item => item.image_path); + const allSelected = visible.length && visible.every(path => state.imageFactorySelectedSources.has(path)); + visible.forEach(path => allSelected ? state.imageFactorySelectedSources.delete(path) : state.imageFactorySelectedSources.add(path)); + renderImageFactorySources(); updateImageFactorySelection(); + }); + document.getElementById('image-factory-clear-sources')?.addEventListener('click', () => { state.imageFactorySelectedSources.clear(); renderImageFactorySources(); updateImageFactorySelection(); }); + document.getElementById('image-factory-bg-sensitivity')?.addEventListener('input', event => { document.getElementById('image-factory-bg-value').textContent = event.target.value; }); + document.getElementById('image-factory-watermark-opacity')?.addEventListener('input', event => { document.getElementById('image-factory-watermark-value').textContent = `${event.target.value} %`; }); + document.getElementById('image-factory-new-profile')?.addEventListener('click', () => openImageFactoryProfileModal()); + document.getElementById('image-factory-profile-close')?.addEventListener('click', closeImageFactoryProfileModal); + document.getElementById('image-factory-profile-cancel')?.addEventListener('click', closeImageFactoryProfileModal); + document.getElementById('image-factory-profile-save')?.addEventListener('click', saveImageFactoryProfile); + document.getElementById('image-factory-profile-modal')?.addEventListener('click', event => { if (event.target.id === 'image-factory-profile-modal') closeImageFactoryProfileModal(); }); + document.getElementById('image-factory-job-page-size')?.addEventListener('change', event => { state.imageFactoryJobPageSize = Number(event.target.value || 10); state.imageFactoryJobPage = 1; loadImageFactoryJobs(); }); + document.getElementById('image-factory-job-prev')?.addEventListener('click', () => { if (state.imageFactoryJobPage > 1) { state.imageFactoryJobPage -= 1; loadImageFactoryJobs(); } }); + document.getElementById('image-factory-job-next')?.addEventListener('click', () => { if (state.imageFactoryJobPage < Number(state.imageFactoryJobPagination.total_pages || 1)) { state.imageFactoryJobPage += 1; loadImageFactoryJobs(); } }); + document.getElementById('media-send-to-factory')?.addEventListener('click', () => { + for (const imagePath of state.mediaSelected) state.imageFactorySelectedSources.add(imagePath); + switchToTab('image-factory'); + toast(`${state.mediaSelected.size} Galeriebild(er) für die Bildproduktion übernommen.`); + }); +} + + +// --- Article Quality Center --- +function qualityTone(report) { + if (report.blockers > 0) return 'blocked'; + if (report.ready) return 'ready'; + return 'review'; +} + +function qualityStatusLabel(report) { + if (report.blockers > 0) return `${report.blockers} Blocker`; + if (report.ready) return 'Bereit'; + return 'Prüfen'; +} + +function qualityIssueIcon(severity) { + const names = { error: 'x', warning: 'triangle-alert', recommendation: 'sparkles' }; + return window.VendooIcons?.svg(names[severity] || 'circle-help', { size: 18, strokeWidth: 2 }) || (severity === 'error' ? '!' : '·'); +} + +function renderQualityList() { + const container = document.getElementById('quality-list'); + if (!container) return; + if (!state.qualityItems.length) { + container.innerHTML = '

                          Keine Artikel entsprechen den gewählten Filtern.

                          '; + return; + } + container.innerHTML = state.qualityItems.map(report => { + const item = report.listing || {}; + const active = Number(state.qualityCurrent?.listing_id) === Number(report.listing_id); + const tone = qualityTone(report); + return ``; + }).join(''); + container.querySelectorAll('[data-quality-listing-id]').forEach(button => button.addEventListener('click', () => loadQualityDetail(button.dataset.qualityListingId))); +} + +function renderQualitySummary() { + const summary = state.qualitySummary || {}; + const values = { + 'quality-summary-average': summary.average_score ?? 0, + 'quality-summary-ready': summary.ready ?? 0, + 'quality-summary-blocked': summary.blocked ?? 0, + 'quality-summary-total': summary.total ?? 0, + }; + Object.entries(values).forEach(([id, value]) => { const el = document.getElementById(id); if (el) el.textContent = value; }); +} + +function renderQualityPagination() { + const pagination = state.qualityPagination || {}; + const page = Number(pagination.page || 1); const totalPages = Number(pagination.total_pages || 1); const total = Number(pagination.total || 0); + const start = total ? (page - 1) * Number(pagination.page_size || state.qualityPageSize) + 1 : 0; + const end = Math.min(total, page * Number(pagination.page_size || state.qualityPageSize)); + const info = document.getElementById('quality-page-info'); if (info) info.textContent = `Seite ${page} von ${totalPages}`; + const range = document.getElementById('quality-list-range'); if (range) range.textContent = total ? `${start}–${end} von ${total}` : '0 Artikel'; + const prev = document.getElementById('quality-prev'); const next = document.getElementById('quality-next'); + if (prev) prev.disabled = page <= 1; if (next) next.disabled = page >= totalPages; +} + +async function loadQualityCenter({ silent = false } = {}) { + const container = document.getElementById('quality-list'); + if (!silent && container) container.innerHTML = '

                          Artikelqualität wird geladen …

                          '; + const params = new URLSearchParams({ + q: document.getElementById('quality-search')?.value.trim() || '', + status: document.getElementById('quality-status')?.value || 'all', + grade: document.getElementById('quality-grade')?.value || 'all', + page: String(state.qualityPage), + page_size: String(state.qualityPageSize), + }); + try { + const result = await api('GET', `/api/quality-center?${params}`); + state.qualityItems = result.items || []; + state.qualityPagination = result.pagination || { page: 1, total_pages: 1, total: 0 }; + state.qualitySummary = result.summary || {}; + renderQualitySummary(); renderQualityPagination(); renderQualityList(); + if (!state.qualityCurrent && state.qualityItems[0]) loadQualityDetail(state.qualityItems[0].listing_id, { quiet: true }); + } catch (error) { + if (container) container.innerHTML = `

                          Quality Center konnte nicht geladen werden: ${escapeHtml(error.message)}

                          `; + } +} + +function renderQualityIssues(report) { + const container = document.getElementById('quality-issues'); + if (!container) return; + const filter = state.qualityIssueFilter; + const issues = (report.issues || []).filter(item => filter === 'all' || item.severity === filter); + if (!issues.length) { + container.innerHTML = '
                          Keine Punkte in diesem BereichDer Artikel erfüllt die aktuell geprüften Qualitätsregeln.
                          '; + return; + } + container.innerHTML = issues.map(item => `
                          + ${qualityIssueIcon(item.severity)} +
                          ${escapeHtml(item.title)}

                          ${escapeHtml(item.detail || '')}

                          ${escapeHtml(item.category || '')}${item.field ? ` · Feld: ${escapeHtml(item.field)}` : ''}
                          + −${Number(item.points || 0)} +
                          `).join(''); +} + +function renderQualityDetail(report) { + state.qualityCurrent = report; + document.getElementById('quality-detail-empty')?.classList.add('hidden'); + document.getElementById('quality-detail-content')?.classList.remove('hidden'); + const title = document.getElementById('quality-detail-title'); if (title) title.textContent = report.listing?.title || 'Unbenannter Artikel'; + const platform = document.getElementById('quality-detail-platform'); if (platform) platform.textContent = `${report.platform?.name || platformLabel(report.listing?.platform)} · Note ${report.grade}`; + const meta = document.getElementById('quality-detail-meta'); if (meta) meta.textContent = `${report.listing?.sku || 'Ohne SKU'} · ${report.facts?.photo_count || 0} Bilder · ${report.facts?.title_length || 0} Titelzeichen`; + const score = document.getElementById('quality-detail-score'); if (score) score.textContent = report.score; + const ring = document.getElementById('quality-score-ring'); if (ring) { ring.dataset.grade = report.grade || 'E'; ring.style.setProperty('--quality-score', `${report.score * 3.6}deg`); } + const readiness = document.getElementById('quality-readiness'); + if (readiness) readiness.innerHTML = `${report.ready ? 'Bereit zur Veröffentlichung' : report.blockers ? 'Veröffentlichung blockiert' : 'Überarbeitung empfohlen'}${report.blockers || 0} Blocker · ${report.warnings || 0} Warnungen · ${report.recommendations || 0} Empfehlungen`; + const finance = report.financials || {}; + const financials = document.getElementById('quality-financials'); + if (financials) financials.innerHTML = `
                          Preis${formatCurrency(finance.price || report.listing?.price || 0)}
                          Gebühren${formatCurrency(finance.fees || 0)}
                          Geschätzter Erlös${formatCurrency(finance.estimated_net || 0)}
                          `; + renderQualityIssues(report); + const duplicates = document.getElementById('quality-duplicates'); + if (duplicates) duplicates.innerHTML = report.duplicates?.length ? report.duplicates.map(item => ``).join('') : '

                          Keine auffälligen Duplikate gefunden.

                          '; + duplicates?.querySelectorAll('[data-quality-duplicate-id]').forEach(button => button.addEventListener('click', () => showDetail(button.dataset.qualityDuplicateId))); + const images = document.getElementById('quality-images'); + if (images) images.innerHTML = report.images?.length ? report.images.map(item => `
                          ${item.missing || item.unreadable ? '!' : '✓'}
                          ${escapeHtml(item.path || 'Bild')}${item.width ? `${item.width} × ${item.height} px · ${item.megapixels} MP · ${escapeHtml(item.format || '')}` : item.missing ? 'Datei fehlt' : 'Nicht lesbar'}
                          `).join('') : '

                          Für eine vollständige Bildprüfung bitte „Neu analysieren“ verwenden.

                          '; + const history = document.getElementById('quality-history'); + if (history) history.innerHTML = report.history?.length ? report.history.map(item => `
                          ${new Date(String(item.analyzed_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE')}${item.score}Note ${item.grade}
                          `).join('') : '

                          Noch kein gespeicherter Verlauf.

                          '; + const delta = document.getElementById('quality-score-delta'); + if (delta) { const value = Number(report.comparison?.delta || 0); delta.textContent = value ? `${value > 0 ? '+' : ''}${value} seit letzter Prüfung` : 'Noch kein Vergleich'; delta.dataset.tone = value > 0 ? 'up' : value < 0 ? 'down' : 'neutral'; } + renderQualityList(); +} + +async function loadQualityDetail(listingId, { quiet = false } = {}) { + const panel = document.getElementById('quality-detail-content'); + if (!quiet && panel) panel.classList.add('is-loading'); + try { + const report = await api('GET', `/api/quality-center/${listingId}`, null, { timeoutMs: 20000 }); + renderQualityDetail(report); + } catch (error) { toast(`Qualitätsprüfung fehlgeschlagen: ${error.message}`); } + finally { panel?.classList.remove('is-loading'); } +} + +async function runQualityAnalysis(listingId = state.qualityCurrent?.listing_id) { + if (!listingId) return; + const button = document.getElementById('quality-run-analysis'); const original = button?.textContent; + if (button) { button.disabled = true; button.textContent = 'Analyse läuft …'; } + try { + const report = await api('POST', `/api/quality-center/${listingId}/analyze`, {}, { timeoutMs: 30000 }); + renderQualityDetail(report); await loadQualityCenter({ silent: true }); toast(`Qualitätsanalyse abgeschlossen: ${report.score}/100`); + } catch (error) { toast(`Analyse fehlgeschlagen: ${error.message}`); } + finally { if (button) { button.disabled = false; button.textContent = original || 'Neu analysieren'; } } +} + +async function analyzeVisibleQualityItems() { + const ids = state.qualityItems.map(item => item.listing_id).slice(0, 25); + if (!ids.length) return toast('Keine sichtbaren Artikel vorhanden.'); + const button = document.getElementById('quality-analyze-visible'); const original = button?.textContent; + if (button) button.disabled = true; + let done = 0; let failed = 0; + for (const id of ids) { + if (button) button.textContent = `${done + 1} / ${ids.length} prüfen`; + try { await api('POST', `/api/quality-center/${id}/analyze`, {}, { timeoutMs: 30000 }); done += 1; } catch { failed += 1; } + } + if (button) { button.disabled = false; button.textContent = original || 'Sichtbare prüfen'; } + await loadQualityCenter({ silent: true }); + if (state.qualityCurrent?.listing_id) await loadQualityDetail(state.qualityCurrent.listing_id, { quiet: true }); + toast(`${done} Artikel geprüft${failed ? ` · ${failed} fehlgeschlagen` : ''}.`); +} + +function openQualityAiModal() { + if (!state.qualityCurrent?.listing_id) return; + state.qualityAiDraft = null; + const modal = document.getElementById('quality-ai-modal'); modal?.classList.remove('hidden'); + document.getElementById('quality-ai-compare')?.classList.add('hidden'); + document.getElementById('quality-ai-apply')?.classList.add('hidden'); + const status = document.getElementById('quality-ai-status'); if (status) status.textContent = 'Wähle die Felder und erstelle einen Vorschlag.'; + const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = ''; +} + +function closeQualityAiModal() { document.getElementById('quality-ai-modal')?.classList.add('hidden'); state.qualityAiDraft = null; } + +async function generateQualityAiSuggestion() { + const fields = [['title','quality-ai-field-title'],['description','quality-ai-field-description'],['tags','quality-ai-field-tags']].filter(([, id]) => document.getElementById(id)?.checked).map(([field]) => field); + if (!fields.length) return toast('Bitte mindestens ein Feld auswählen.'); + const button = document.getElementById('quality-ai-generate'); const status = document.getElementById('quality-ai-status'); + if (button) button.disabled = true; if (status) status.textContent = 'Der aktive AI-Provider erstellt sichere Vorschläge …'; + try { + const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/ai-suggestions`, { fields, mode: document.getElementById('quality-ai-mode')?.value || 'balanced' }, { timeoutMs: 60000 }); + state.qualityAiDraft = result; + const original = result.original || {}; const suggestions = result.suggestions || {}; + document.getElementById('quality-ai-original-title').value = original.title || ''; + document.getElementById('quality-ai-original-description').value = original.description || ''; + document.getElementById('quality-ai-original-tags').value = (original.tags || []).join(', '); + document.getElementById('quality-ai-suggested-title').value = suggestions.title ?? original.title ?? ''; + document.getElementById('quality-ai-suggested-description').value = suggestions.description ?? original.description ?? ''; + document.getElementById('quality-ai-suggested-tags').value = (suggestions.tags ?? original.tags ?? []).join(', '); + document.getElementById('quality-ai-compare')?.classList.remove('hidden'); document.getElementById('quality-ai-apply')?.classList.remove('hidden'); + const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = (suggestions.reasons || []).map(reason => `${escapeHtml(reason)}`).join(''); + if (status) status.textContent = `Vorschlag über ${result.provider} erstellt. Prüfe und bearbeite ihn vor dem Übernehmen.`; + } catch (error) { if (status) status.textContent = `AI-Vorschlag fehlgeschlagen: ${error.message}`; } + finally { if (button) button.disabled = false; } +} + +async function applyQualityAiSuggestion() { + if (!state.qualityAiDraft || !state.qualityCurrent?.listing_id) return; + const payload = {}; + if (document.getElementById('quality-ai-field-title')?.checked) payload.title = document.getElementById('quality-ai-suggested-title').value.trim(); + if (document.getElementById('quality-ai-field-description')?.checked) payload.description = document.getElementById('quality-ai-suggested-description').value.trim(); + if (document.getElementById('quality-ai-field-tags')?.checked) payload.tags = document.getElementById('quality-ai-suggested-tags').value.split(',').map(value => value.trim()).filter(Boolean); + const button = document.getElementById('quality-ai-apply'); if (button) button.disabled = true; + try { + const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/apply-suggestions`, payload, { timeoutMs: 30000 }); + closeQualityAiModal(); renderQualityDetail(result.report); await loadQualityCenter({ silent: true }); toast('Bestätigte Verbesserungen wurden gespeichert.'); + } catch (error) { toast(`Verbesserungen konnten nicht gespeichert werden: ${error.message}`); } + finally { if (button) button.disabled = false; } +} + +function setupQualityCenter() { + document.getElementById('quality-refresh')?.addEventListener('click', () => loadQualityCenter()); + document.getElementById('quality-analyze-visible')?.addEventListener('click', analyzeVisibleQualityItems); + document.getElementById('quality-search')?.addEventListener('input', debounce(() => { state.qualityPage = 1; loadQualityCenter({ silent: true }); }, 300)); + document.getElementById('quality-status')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-grade')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-page-size')?.addEventListener('change', event => { state.qualityPageSize = Number(event.target.value || 10); state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-prev')?.addEventListener('click', () => { if (state.qualityPage > 1) { state.qualityPage -= 1; loadQualityCenter(); } }); + document.getElementById('quality-next')?.addEventListener('click', () => { if (state.qualityPage < Number(state.qualityPagination.total_pages || 1)) { state.qualityPage += 1; loadQualityCenter(); } }); + document.getElementById('quality-run-analysis')?.addEventListener('click', () => runQualityAnalysis()); + document.getElementById('quality-open-article')?.addEventListener('click', () => state.qualityCurrent?.listing_id && showDetail(state.qualityCurrent.listing_id)); + document.getElementById('quality-open-ai')?.addEventListener('click', openQualityAiModal); + document.querySelectorAll('[data-quality-issue-filter]').forEach(button => button.addEventListener('click', () => { + state.qualityIssueFilter = button.dataset.qualityIssueFilter || 'all'; + document.querySelectorAll('[data-quality-issue-filter]').forEach(item => item.classList.toggle('active', item === button)); + if (state.qualityCurrent) renderQualityIssues(state.qualityCurrent); + })); + document.getElementById('quality-ai-close')?.addEventListener('click', closeQualityAiModal); + document.getElementById('quality-ai-cancel')?.addEventListener('click', closeQualityAiModal); + document.getElementById('quality-ai-generate')?.addEventListener('click', generateQualityAiSuggestion); + document.getElementById('quality-ai-apply')?.addEventListener('click', applyQualityAiSuggestion); + document.getElementById('quality-ai-modal')?.addEventListener('click', event => { if (event.target.id === 'quality-ai-modal') closeQualityAiModal(); }); +} + +// --- System Diagnostics --- +function diagnosticTone(status) { + return status === 'ok' ? 'ok' : status === 'error' ? 'error' : status === 'warning' ? 'warning' : 'optional'; +} + +function diagnosticIcon(status) { + const name = status === 'ok' ? 'shield-check' : status === 'error' ? 'x' : 'circle-help'; + return window.VendooIcons?.svg(name, { size: 18, strokeWidth: 2 }) || ''; +} + +function renderSystemDiagnostics(report) { + const summary = document.getElementById('system-diagnostics-summary'); + const grid = document.getElementById('system-diagnostics-grid'); + const copy = document.getElementById('system-diagnostics-copy'); + if (!summary || !grid) return; + const errors = report.checks?.filter(item => item.status === 'error').length || 0; + const warnings = report.checks?.filter(item => ['warning', 'optional'].includes(item.status)).length || 0; + summary.className = `diagnostics-summary ${errors ? 'is-error' : warnings ? 'is-warning' : 'is-ok'}`; + summary.innerHTML = `${errors ? `${errors} kritische Prüfung(en) fehlgeschlagen` : 'Vendoo-Kernsystem ist betriebsbereit'}${warnings ? `${warnings} Hinweis(e) · ` : ''}${report.checks?.length || 0} Prüfungen in ${report.duration_ms || 0} ms · Build ${escapeHtml(report.version || VENDOO_UI_BUILD)}`; + grid.innerHTML = (report.checks || []).map(check => ` +
                          + ${diagnosticIcon(check.status)} +
                          ${escapeHtml(check.label)}

                          ${escapeHtml(check.detail || '')}

                          + ${check.status === 'ok' ? 'OK' : check.status === 'error' ? 'Fehler' : check.status === 'warning' ? 'Hinweis' : 'Optional'} +
                          `).join(''); + if (copy) copy.disabled = false; +} + +async function runSystemDiagnostics() { + const button = document.getElementById('system-diagnostics-btn'); + const summary = document.getElementById('system-diagnostics-summary'); + const grid = document.getElementById('system-diagnostics-grid'); + const original = button?.textContent || 'Systemprüfung starten'; + if (button) { button.disabled = true; button.textContent = 'Prüfung läuft …'; } + if (summary) { summary.className = 'diagnostics-summary is-running'; summary.textContent = 'Datenbank, Dateisystem, UI, Extensions und optionale Dienste werden geprüft …'; } + if (grid) grid.innerHTML = '
                          '; + try { + const report = await api('GET', '/api/admin/diagnostics', null, { retry: false, timeoutMs: 15000 }); + clientDiagnostics.lastSystemReport = report; + renderSystemDiagnostics(report); + toast(report.ok ? 'Systemprüfung erfolgreich.' : 'Systemprüfung hat kritische Fehler gefunden.'); + } catch (error) { + if (summary) { summary.className = 'diagnostics-summary is-error'; summary.textContent = `Systemprüfung fehlgeschlagen: ${error.message}`; } + if (grid) grid.innerHTML = ''; + toast(`Systemprüfung fehlgeschlagen: ${error.message}`); + } finally { + if (button) { button.disabled = false; button.textContent = original; } + } +} + +async function copySystemDiagnostics() { + const report = clientDiagnostics.lastSystemReport; + if (!report) return toast('Bitte zuerst eine Systemprüfung starten.'); + const text = [ + `Vendoo Diagnose ${report.version || VENDOO_UI_BUILD}`, + `Erstellt: ${report.generated_at || new Date().toISOString()}`, + `Runtime: ${report.runtime?.node || '?'} · ${report.runtime?.platform || '?'} ${report.runtime?.arch || ''}`, + '', + ...(report.checks || []).map(check => `[${String(check.status).toUpperCase()}] ${check.label}: ${check.detail}`), + '', + `Client-Fehler (letzte ${clientDiagnostics.incidents.length}):`, + ...clientDiagnostics.incidents.slice(0, 10).map(item => `${item.at} · ${item.type} · ${item.view} · ${item.message} · ${item.requestId || ''}`), + ].join('\n'); + try { + await navigator.clipboard.writeText(text); + toast('Diagnose in die Zwischenablage kopiert.'); + } catch { + const area = document.createElement('textarea'); + area.value = text; document.body.append(area); area.select(); document.execCommand('copy'); area.remove(); + toast('Diagnose kopiert.'); + } +} + +// --- System Info --- +async function loadSystemInfo() { + try { + const sys = await api('GET', '/api/admin/system'); + document.getElementById('system-info-cards').innerHTML = ` +
                          +
                          Server Uptime
                          +
                          ${sys.uptime}
                          +
                          +
                          +
                          Benutzer
                          +
                          ${sys.total_users}
                          +
                          +
                          +
                          Artikel
                          +
                          ${sys.total_listings}
                          +
                          +
                          +
                          Aktive Sessions
                          +
                          ${sys.active_sessions}
                          +
                          +
                          +
                          Datenbankgröße
                          +
                          ${sys.db_size}
                          +
                          +
                          +
                          Node.js
                          +
                          ${sys.node_version}
                          +
                          +
                          +
                          Speicher (RAM)
                          +
                          ${sys.memory_mb} MB
                          +
                          +
                          +
                          Plattform
                          +
                          ${sys.platform}
                          +
                          + `; + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadMySessions() { + try { + const sessions = await api('GET', '/api/my/sessions'); + const container = document.getElementById('my-sessions-list'); + container.innerHTML = sessions.length ? sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                          +
                          +
                          ${ua}
                          +
                          IP: ${s.ip || 'unbekannt'}
                          +
                          +
                          ${timeAgo(s.created_at)}
                          + +
                          `; + }).join('') : '
                          Keine aktiven Sessions
                          '; + document.getElementById('my-sessions-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function endMySession(sessionId) { + try { + await api('DELETE', `/api/my/sessions/${sessionId}`); + toast('Session beendet'); + loadMySessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +function parseUA(ua) { + if (!ua) return 'Unbekannt'; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Firefox')) return 'Firefox'; + if (ua.includes('Safari')) return 'Safari'; + if (ua.includes('Edge')) return 'Edge'; + return ua.substring(0, 40); +} + +init().catch(error => { + recordClientIncident('startup', error?.message || String(error)); + setConnectionState(false, `Vendoo konnte nicht vollständig gestartet werden: ${error?.message || 'Unbekannter Fehler'}`); + console.error('[Vendoo] Initialisierung fehlgeschlagen', error); +}); + +// --- Vendoo 1.29.0 Publishing Hardening UI --- +publishState.jobsData = { items: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 1 }, summary: {} }; +publishState.jobPage = 1; +publishState.jobPageSize = 10; +publishState.jobStatus = ''; +publishState.jobPlatform = ''; +publishState.jobSearch = ''; +publishState.jobSelected = new Set(); +publishState.publishEvents = []; +publishState.capabilities = null; + +async function loadPublishingQueueData({ render = true } = {}) { + const params = new URLSearchParams({ page: String(publishState.jobPage), page_size: String(publishState.jobPageSize) }); + if (publishState.jobStatus) params.set('status', publishState.jobStatus); + if (publishState.jobPlatform) params.set('platform', publishState.jobPlatform); + if (publishState.jobSearch) params.set('q', publishState.jobSearch); + const [jobs, events, capabilities] = await Promise.all([ + publishSafeGet(`/api/publishing/jobs?${params}`, { items: [], pagination: { page: 1, page_size: publishState.jobPageSize, total: 0, total_pages: 1 }, summary: {} }, 2), + publishSafeGet('/api/publishing/events?limit=120', [], 2), + publishSafeGet('/api/publishing/capabilities', null, 2), + ]); + publishState.jobsData = jobs; + publishState.publishEvents = Array.isArray(events) ? events : []; + publishState.capabilities = capabilities; + const visible = new Set((jobs.items || []).map(item => item.id)); + publishState.jobSelected = new Set([...publishState.jobSelected].filter(id => visible.has(id))); + if (render) { + renderPublishWorkspace(); + renderPublishStatusRail(); + } +} + +async function loadPublishList() { + await loadPublishListLegacy(); + try { await loadPublishingQueueData(); } + catch (error) { console.warn('[Vendoo] Publishing-Queue konnte nicht geladen werden', error); } +} + +function setPublishView(view) { + publishState.view = ['smart', 'direct', 'queue', 'errors', 'scheduled'].includes(view) ? view : 'smart'; + document.querySelectorAll('[data-publish-view]').forEach(button => { + const active = button.dataset.publishView === publishState.view; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + renderPublishWorkspace(); +} + +function renderPublishWorkspace() { + if (publishState.view === 'queue') { renderQueueWorkspace(); return; } + if (publishState.view === 'errors') { renderPublishingErrorsWorkspace(); return; } + if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; } + const listing = selectedPublishListing(); + const workspace = document.getElementById('publish-workspace'); + if (!workspace) return; + if (!listing) { + workspace.innerHTML = `

                          Artikel auswählen

                          Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.

                          `; + return; + } + if (publishState.view === 'direct') renderDirectPublishWorkspace(listing); + else renderSmartCopyLauncher(listing); +} + +function publishingStatusLabel(status) { + return ({ queued: 'Wartend', running: 'In Bearbeitung', awaiting_user: 'Wartet auf Extension', retry_wait: 'Wiederholung geplant', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', blocked: 'Blockiert', ended: 'Beendet' })[status] || status || 'Unbekannt'; +} +function publishingActionLabel(action) { + return ({ publish: 'Veröffentlichen', update: 'Aktualisieren', end: 'Beenden', sync: 'Status abgleichen' })[action] || action; +} +function publishingModeLabel(mode) { return mode === 'api' ? 'Offizielle API' : 'Browser-Extension'; } +function publishingCan(job, action) { + return Boolean(publishState.capabilities?.platforms?.[job.platform]?.[action]); +} +function publishingDate(value) { + if (!value) return '—'; + const date = new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }); +} + +function renderQueueWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const data = publishState.jobsData || { items: [], pagination: {}, summary: {} }; + const items = data.items || []; + const page = data.pagination?.page || 1; + const pages = data.pagination?.total_pages || 1; + const total = data.pagination?.total || 0; + const selectedCount = publishState.jobSelected.size; + const platformOptions = ['', 'ebay-de', 'etsy', 'vinted', 'ebay-ka'].map(value => ``).join(''); + const statusOptions = ['', 'queued', 'running', 'awaiting_user', 'retry_wait', 'published', 'drafted', 'failed', 'cancelled', 'ended'].map(value => ``).join(''); + + workspace.innerHTML = `
                          +
                          Zuverlässige Verarbeitung

                          Publishing-Queue

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

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

                          Keine Aufträge

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

                          '}
                          +
                          Seite ${page} von ${pages}
                          `; + + workspace.querySelector('[data-publishing-refresh]')?.addEventListener('click', () => loadPublishingQueueData()); + workspace.querySelector('[data-publishing-add]')?.addEventListener('click', batchEbayAdd); + workspace.querySelector('[data-publishing-search]')?.addEventListener('input', debounce(event => { publishState.jobSearch = event.target.value.trim(); publishState.jobPage = 1; loadPublishingQueueData(); }, 280)); + workspace.querySelector('[data-publishing-platform]')?.addEventListener('change', event => { publishState.jobPlatform = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-status]')?.addEventListener('change', event => { publishState.jobStatus = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-size]')?.addEventListener('change', event => { publishState.jobPageSize = Number(event.target.value); publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-prev]')?.addEventListener('click', () => { publishState.jobPage = Math.max(1, page - 1); loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-next]')?.addEventListener('click', () => { publishState.jobPage = Math.min(pages, page + 1); loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-select-page]')?.addEventListener('change', event => { + for (const item of items) event.target.checked ? publishState.jobSelected.add(item.id) : publishState.jobSelected.delete(item.id); + renderQueueWorkspace(); + }); + workspace.querySelectorAll('[data-publishing-check]').forEach(input => input.addEventListener('change', event => { + event.target.checked ? publishState.jobSelected.add(event.target.dataset.publishingCheck) : publishState.jobSelected.delete(event.target.dataset.publishingCheck); + renderQueueWorkspace(); + })); + workspace.querySelector('[data-publishing-delete-selected]')?.addEventListener('click', deleteSelectedPublishingJobs); + workspace.querySelectorAll('[data-publishing-action]').forEach(button => button.addEventListener('click', () => runPublishingJobAction(button.dataset.publishingId, button.dataset.publishingAction))); +} + +function renderPublishingJobRow(job) { + const canCancel = ['queued', 'running', 'awaiting_user', 'retry_wait'].includes(job.status); + const canRetry = ['failed', 'blocked', 'cancelled'].includes(job.status); + const canDelete = ['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked'].includes(job.status); + const isLive = job.status === 'published'; + return `
                          + +
                          ${escapeHtml(String(job.sku || 'VD').slice(-2))}
                          ${escapeHtml(job.title || 'Ohne Titel')}${escapeHtml(job.sku || '')} · ${escapeHtml(platformLabel(job.platform))}
                          +
                          Aktion${escapeHtml(publishingActionLabel(job.action))}${escapeHtml(publishingModeLabel(job.mode))}
                          +
                          Status${escapeHtml(publishingStatusLabel(job.status))}${escapeHtml(publishingDate(job.updated_at))}
                          +
                          Versuche${Number(job.retries)}/${Number(job.max_retries)}${job.next_attempt_at ? `Nächster: ${escapeHtml(publishingDate(job.next_attempt_at))}` : ''}
                          +
                          ${job.error_message ? `${escapeHtml(job.error_message)}` : job.external_id ? `Externe ID: ${escapeHtml(job.external_id)}` : 'Keine Fehler'}
                          +
                          + ${job.external_url ? `` : ''} + ${isLive && publishingCan(job, 'sync') ? `` : ''} + ${isLive && publishingCan(job, 'update') ? `` : ''} + ${isLive && publishingCan(job, 'end') ? `` : ''} + ${canRetry ? `` : ''} + ${canCancel ? `` : ''} + ${canDelete ? `` : ''} +
                          +
                          `; +} + +async function runPublishingJobAction(id, action) { + try { + if (action === 'retry') await api('POST', `/api/publishing/jobs/${id}/retry`); + else if (action === 'cancel') await api('POST', `/api/publishing/jobs/${id}/cancel`); + else if (action === 'delete') { + if (!confirm('Diesen abgeschlossenen Publishing-Auftrag aus dem Protokoll löschen? Das externe Angebot bleibt unberührt.')) return; + await api('DELETE', '/api/publishing/jobs', { ids: [id] }); + } else if (['sync', 'update', 'end'].includes(action)) { + if (action === 'end' && !confirm('Das externe Angebot wirklich beenden?')) return; + await api('POST', `/api/publishing/jobs/${id}/action`, { action }); + } + toast(action === 'sync' ? 'Statusabgleich eingereiht' : action === 'update' ? 'Aktualisierung eingereiht' : action === 'end' ? 'Beenden eingereiht' : 'Auftrag aktualisiert'); + await loadPublishingQueueData(); + } catch (error) { toast(`Publishing-Aktion fehlgeschlagen: ${error.message}`); } +} + +async function deleteSelectedPublishingJobs() { + const ids = [...publishState.jobSelected]; + if (!ids.length || !confirm(`${ids.length} abgeschlossene Publishing-Aufträge aus dem lokalen Protokoll löschen?`)) return; + try { + const result = await api('DELETE', '/api/publishing/jobs', { ids }); + toast(`${result.deleted} Aufträge gelöscht`); + publishState.jobSelected.clear(); + await loadPublishingQueueData(); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function renderPublishingErrorsWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const events = publishState.publishEvents || []; + const errors = events.filter(event => event.level === 'error' || ['failed', 'blocked', 'retry_wait'].includes(event.event_type)); + workspace.innerHTML = `
                          Diagnose & Audit

                          Fehlerzentrale und Veröffentlichungsprotokoll

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

                          +
                          ${errors.length}Fehlerereignisse
                          ${events.filter(item => item.event_type === 'completed').length}Erfolgreiche Abschlüsse
                          ${events.filter(item => item.event_type === 'retry_wait').length}Automatische Wiederholungen
                          +
                          ${events.length ? events.map(renderPublishingEvent).join('') : '

                          Keine Ereignisse

                          Neue Veröffentlichungsaktivitäten erscheinen hier.

                          '}
                          `; + workspace.querySelector('[data-publishing-events-refresh]')?.addEventListener('click', () => loadPublishingQueueData()); + workspace.querySelectorAll('[data-event-open-job]').forEach(button => button.addEventListener('click', () => { + publishState.jobSearch = button.dataset.eventOpenJob; + publishState.jobStatus = ''; + publishState.jobPage = 1; + setPublishView('queue'); + loadPublishingQueueData(); + })); +} + +function renderPublishingEvent(event) { + return `
                          + +
                          ${escapeHtml(event.message)}${escapeHtml(event.title || event.sku || '')} · ${escapeHtml(platformLabel(event.platform))} · ${escapeHtml(publishingDate(event.created_at))}
                          + ${escapeHtml(event.event_type)} + ${event.job_id ? `` : ''} +
                          `; +} + +async function batchEbayAdd() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + let created = 0; + let duplicates = 0; + let failed = 0; + for (const id of ids) { + const listing = publishState.listings.find(item => item.id === id) || publishState.allFiltered.find(item => item.id === id); + if (!listing) continue; + try { + const result = await api('POST', '/api/publishing/jobs', { listing_id: id, platform: listing.platform, action: 'publish' }); + created += result.jobs?.length || 0; + duplicates += result.duplicates || 0; + } catch (error) { + if (error.code === 'DUPLICATE_PUBLISH' || /bereits.*veröffentlicht/i.test(error.message)) duplicates++; + else failed++; + } + } + toast(`${created} Auftrag${created === 1 ? '' : 'e'} eingereiht${duplicates ? ` · ${duplicates} bereits vorhanden/live` : ''}${failed ? ` · ${failed} fehlgeschlagen` : ''}`); + publishState.view = 'queue'; + await loadPublishList(); + setPublishView('queue'); +} + +async function processEbayQueue() { + setPublishView('queue'); + await loadPublishingQueueData(); +} +async function retryEbayQueue() { + setPublishView('errors'); + await loadPublishingQueueData(); +} +async function openEbayQueue() { + setPublishView('queue'); + await loadPublishingQueueData(); +} + +function publishQueueCounts() { + const summary = publishState.jobsData?.summary || {}; + return { + pending: Number(summary.queued || 0) + Number(summary.awaiting_user || 0) + Number(summary.retry_wait || 0), + processing: Number(summary.running || 0), + published: Number(summary.published || 0), + failed: Number(summary.failed || 0) + Number(summary.blocked || 0), + }; +} + +function renderPublishStatusRail() { + const counts = publishQueueCounts(); + const summary = publishState.jobsData?.summary || {}; + const workflowPublished = publishState.workflow?.stages?.published?.count || 0; + const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length; + const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; }; + setText('publish-status-pending', counts.pending); + setText('publish-status-processing', counts.processing); + setText('publish-status-published', Math.max(counts.published, workflowPublished)); + setText('publish-status-failed', counts.failed); + setText('publish-status-scheduled', publishState.scheduled.length); + setText('publish-direct-count', directEligible); + setText('publish-queue-count', publishState.jobsData?.pagination?.total || 0); + setText('publish-error-count', counts.failed); + setText('publish-scheduled-count', publishState.scheduled.length); + setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler prüfen` : 'Keine Fehler'); + setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen'); + const container = document.getElementById('publish-activity'); + const events = publishState.publishEvents || []; + if (container) container.innerHTML = events.length ? events.slice(0, 6).map(event => `
                          ${event.level === 'error' ? '!' : event.event_type === 'completed' ? '✓' : '•'}
                          ${escapeHtml(event.message || '')}${relativeTime(event.created_at)}
                          `).join('') : '
                          Noch keine Publishing-Aktivität
                          '; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/apple-touch-icon.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/apple-touch-icon.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-192x192.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-192x192.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-512x512.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/android-chrome-512x512.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/apple-touch-icon.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/apple-touch-icon.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/browserconfig.xml b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/browserconfig.xml new file mode 100644 index 0000000..fa0e64f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #faf6f2 + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-16x16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-16x16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-32x32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-32x32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-48x48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon-48x48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon.ico b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/favicon.ico differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/html-snippet.txt b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/html-snippet.txt new file mode 100644 index 0000000..753dafb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/html-snippet.txt @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/mstile-150x150.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/mstile-150x150.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/mstile-150x150.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/site.webmanifest b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/site.webmanifest new file mode 100644 index 0000000..4d5086d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/favicons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Vendoo", + "short_name": "Vendoo", + "icons": [ + { + "src": "/brand/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/brand/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#FAF6F2", + "background_color": "#FAF6F2", + "display": "standalone" +} \ No newline at end of file diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-120x120.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-120x120.png new file mode 100644 index 0000000..643e525 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-120x120.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-128x128.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-128x128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-128x128.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-144x144.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-144x144.png new file mode 100644 index 0000000..8467167 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-144x144.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-152x152.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-152x152.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-152x152.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-167x167.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-167x167.png new file mode 100644 index 0000000..7bd6ca2 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-167x167.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-16x16.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-16x16.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-180x180.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-180x180.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-180x180.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-192x192.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-192x192.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-256x256.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-256x256.png new file mode 100644 index 0000000..98a4ed0 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-256x256.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-32x32.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-32x32.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-384x384.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-384x384.png new file mode 100644 index 0000000..be778a5 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-384x384.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-48x48.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-48x48.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-512x512.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-512x512.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-64x64.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-64x64.png new file mode 100644 index 0000000..5f7da83 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-64x64.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-72x72.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-72x72.png new file mode 100644 index 0000000..9882902 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-72x72.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-96x96.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-96x96.png new file mode 100644 index 0000000..32b38d3 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-96x96.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-original-1254.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-original-1254.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/icons/vendoo-icon-original-1254.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-cropped.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-cropped.png new file mode 100644 index 0000000..329e51a Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-cropped.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-original.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-original.png new file mode 100644 index 0000000..44ac64d Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-original.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-transparent.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-transparent.png new file mode 100644 index 0000000..2449e44 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-logo-transparent.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-wordmark-transparent.png b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-wordmark-transparent.png new file mode 100644 index 0000000..ad76562 Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/brand/logo/vendoo-wordmark-transparent.png differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/api-client.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/api-client.js new file mode 100644 index 0000000..f4761e9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/api-client.js @@ -0,0 +1,40 @@ +export class VendooApiError extends Error { + constructor(message, { status = 0, code = 'API_ERROR', requestId = null, details = null } = {}) { + super(message); + this.name = 'VendooApiError'; + this.status = status; + this.code = code; + this.requestId = requestId; + this.details = details; + } +} + +function readCookie(name) { + const prefix = `${encodeURIComponent(name)}=`; + return document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith(prefix))?.slice(prefix.length) || ''; +} + +export async function apiRequest(path, { method = 'GET', body, headers = {}, signal } = {}) { + const verb = String(method).toUpperCase(); + const requestHeaders = new Headers(headers); + requestHeaders.set('Accept', 'application/json'); + if (body !== undefined) requestHeaders.set('Content-Type', 'application/json'); + if (!['GET', 'HEAD', 'OPTIONS'].includes(verb)) requestHeaders.set('X-CSRF-Token', decodeURIComponent(readCookie('csrf_token'))); + const response = await fetch(path, { + method: verb, + credentials: 'same-origin', + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + redirect: 'error', + }); + const requestId = response.headers.get('x-vendoo-request-id'); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new VendooApiError(payload.error || `HTTP ${response.status}`, { + status: response.status, + code: payload.code || 'API_ERROR', + requestId: payload.request_id || requestId, + details: payload.details || null, + }); + return payload; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/frontend-module-registry.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/frontend-module-registry.js new file mode 100644 index 0000000..80d1caa --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/frontend-module-registry.js @@ -0,0 +1,51 @@ +const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/; + +export class FrontendModuleRegistry { + #modules = new Map(); + #active = null; + + register(definition) { + const id = String(definition?.id || ''); + if (!MODULE_ID.test(id)) throw new Error(`Ungültige Frontend-Modul-ID: ${id}`); + if (this.#modules.has(id)) throw new Error(`Frontend-Modul bereits registriert: ${id}`); + if (typeof definition.mount !== 'function') throw new Error(`Frontend-Modul benötigt mount(): ${id}`); + this.#modules.set(id, Object.freeze({ + id, + version: String(definition.version || '1.0.0'), + status: String(definition.status || 'native'), + mount: definition.mount, + unmount: typeof definition.unmount === 'function' ? definition.unmount : async () => {}, + })); + } + + async mount(id, root, services = {}) { + if (!(root instanceof Element)) throw new Error('Frontend-Modul benötigt ein gültiges Root-Element.'); + const module = this.#modules.get(id); + if (!module) throw new Error(`Frontend-Modul ist nicht registriert: ${id}`); + await this.unmount(); + const controller = new AbortController(); + const context = Object.freeze({ + root, + services: Object.freeze({ ...services }), + signal: controller.signal, + listen(target, event, handler, options = {}) { + target.addEventListener(event, handler, { ...options, signal: controller.signal }); + }, + }); + await module.mount(context); + this.#active = { module, context, controller }; + return module; + } + + async unmount() { + if (!this.#active) return; + const active = this.#active; + this.#active = null; + active.controller.abort(); + await active.module.unmount(active.context); + } + + list() { + return [...this.#modules.values()].map(({ mount: _mount, unmount: _unmount, ...module }) => ({ ...module })); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/safe-dom.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/safe-dom.js new file mode 100644 index 0000000..09e58ff --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/core/safe-dom.js @@ -0,0 +1,26 @@ +export function setText(node, value) { + if (!(node instanceof Node)) throw new TypeError('DOM-Ziel fehlt.'); + node.textContent = value == null ? '' : String(value); + return node; +} + +export function createElement(tagName, { className = '', text = '', attributes = {}, children = [] } = {}) { + if (!/^[a-z][a-z0-9-]*$/i.test(String(tagName))) throw new TypeError('Ungültiger Elementname.'); + const element = document.createElement(tagName); + if (className) element.className = String(className); + if (text !== '') setText(element, text); + for (const [name, value] of Object.entries(attributes)) { + if (!/^(aria-[a-z-]+|data-[a-z-]+|id|role|title|type|name|value|href|src|alt|tabindex)$/i.test(name)) { + throw new TypeError(`Attribut ist nicht freigegeben: ${name}`); + } + if (['href', 'src'].includes(name.toLowerCase())) { + const url = new URL(String(value), window.location.origin); + if (!['http:', 'https:', 'blob:'].includes(url.protocol) || (['http:', 'https:'].includes(url.protocol) && url.origin !== window.location.origin)) { + throw new TypeError(`Externe oder unsichere URL ist nicht erlaubt: ${name}`); + } + } + element.setAttribute(name, String(value)); + } + for (const child of children) element.append(child); + return element; +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-contract.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-contract.json new file mode 100644 index 0000000..6e5e353 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-contract.json @@ -0,0 +1,333 @@ +{ + "schemaVersion": 1, + "contractVersion": "1.1.0", + "themes": [ + "light", + "dark", + "contrast" + ], + "tokens": { + "color.canvas": { + "css": "--vd-canvas", + "type": "color", + "customizable": true, + "label": "Seitenhintergrund", + "group": "Farben" + }, + "color.surface": { + "css": "--vd-surface", + "type": "color", + "customizable": true, + "label": "Fläche", + "group": "Farben" + }, + "color.surfaceRaised": { + "css": "--vd-surface-raised", + "type": "color", + "customizable": true, + "label": "Erhöhte Fläche", + "group": "Farben" + }, + "color.surfaceMuted": { + "css": "--vd-surface-muted", + "type": "color", + "customizable": true, + "label": "Gedämpfte Fläche", + "group": "Farben" + }, + "color.surfaceQuiet": { + "css": "--vd-surface-quiet", + "type": "color", + "customizable": true, + "label": "Ruhige Fläche", + "group": "Farben" + }, + "color.line": { + "css": "--vd-line", + "type": "color", + "customizable": true, + "label": "Trennlinie", + "group": "Farben" + }, + "color.lineStrong": { + "css": "--vd-line-strong", + "type": "color", + "customizable": true, + "label": "Starke Trennlinie", + "group": "Farben" + }, + "color.ink": { + "css": "--vd-ink", + "type": "color", + "customizable": true, + "label": "Primärtext", + "group": "Farben" + }, + "color.inkSecondary": { + "css": "--vd-ink-secondary", + "type": "color", + "customizable": true, + "label": "Sekundärtext", + "group": "Farben" + }, + "color.inkMuted": { + "css": "--vd-ink-muted", + "type": "color", + "customizable": true, + "label": "Hinweistext", + "group": "Farben" + }, + "color.brand": { + "css": "--vd-brand", + "type": "color", + "customizable": true, + "label": "Akzentfarbe", + "group": "Marke" + }, + "color.brandStrong": { + "css": "--vd-brand-strong", + "type": "color", + "customizable": true, + "label": "Akzentfarbe kräftig", + "group": "Marke" + }, + "color.brandSoft": { + "css": "--vd-brand-soft", + "type": "color", + "customizable": false, + "label": "color.brandSoft", + "group": "Color" + }, + "color.success": { + "css": "--vd-success", + "type": "color", + "customizable": true, + "label": "Erfolg", + "group": "Status" + }, + "color.successSoft": { + "css": "--vd-success-soft", + "type": "color", + "customizable": false, + "label": "color.successSoft", + "group": "Color" + }, + "color.warning": { + "css": "--vd-warning", + "type": "color", + "customizable": true, + "label": "Warnung", + "group": "Status" + }, + "color.warningSoft": { + "css": "--vd-warning-soft", + "type": "color", + "customizable": false, + "label": "color.warningSoft", + "group": "Color" + }, + "color.danger": { + "css": "--vd-danger", + "type": "color", + "customizable": true, + "label": "Gefahr", + "group": "Status" + }, + "color.dangerSoft": { + "css": "--vd-danger-soft", + "type": "color", + "customizable": false, + "label": "color.dangerSoft", + "group": "Color" + }, + "color.info": { + "css": "--vd-info", + "type": "color", + "customizable": true, + "label": "Information", + "group": "Status" + }, + "color.infoSoft": { + "css": "--vd-info-soft", + "type": "color", + "customizable": false, + "label": "color.infoSoft", + "group": "Color" + }, + "color.focus": { + "css": "--vd-focus", + "type": "color", + "customizable": false, + "label": "color.focus", + "group": "Color" + }, + "shadow.float": { + "css": "--vd-shadow-float", + "type": "shadow", + "customizable": false, + "label": "shadow.float", + "group": "Shadow" + }, + "shadow.soft": { + "css": "--vd-shadow-soft", + "type": "shadow", + "customizable": false, + "label": "shadow.soft", + "group": "Shadow" + }, + "radius.xs": { + "css": "--vd-radius-xs", + "type": "dimension", + "customizable": true, + "label": "Rundung klein", + "group": "Form", + "min": 0, + "max": 16 + }, + "radius.sm": { + "css": "--vd-radius-sm", + "type": "dimension", + "customizable": true, + "label": "Rundung mittel", + "group": "Form", + "min": 0, + "max": 20 + }, + "radius.md": { + "css": "--vd-radius-md", + "type": "dimension", + "customizable": true, + "label": "Rundung groß", + "group": "Form", + "min": 0, + "max": 28 + }, + "layout.sidebarWidth": { + "css": "--vd-sidebar-width", + "type": "dimension", + "customizable": false, + "label": "layout.sidebarWidth", + "group": "Layout" + }, + "layout.topbarHeight": { + "css": "--vd-topbar-height", + "type": "dimension", + "customizable": false, + "label": "layout.topbarHeight", + "group": "Layout" + }, + "density.controlHeight": { + "css": "--vd-control-height", + "type": "dimension", + "customizable": true, + "label": "Bedienelementhöhe", + "group": "Dichte", + "min": 32, + "max": 52 + }, + "density.contentGap": { + "css": "--vd-content-gap", + "type": "dimension", + "customizable": true, + "label": "Inhaltsabstand", + "group": "Dichte", + "min": 8, + "max": 32 + }, + "motion.fast": { + "css": "--vd-motion-fast", + "type": "duration", + "customizable": false, + "label": "motion.fast", + "group": "Motion" + }, + "motion.normal": { + "css": "--vd-motion-normal", + "type": "duration", + "customizable": false, + "label": "motion.normal", + "group": "Motion" + }, + "component.buttonHeight": { + "css": "--vd-component-button-height", + "type": "dimension", + "customizable": true, + "label": "Button-Höhe", + "group": "Komponenten", + "min": 32, + "max": 56 + }, + "component.buttonRadius": { + "css": "--vd-component-button-radius", + "type": "dimension", + "customizable": true, + "label": "Button-Rundung", + "group": "Komponenten", + "min": 0, + "max": 24 + }, + "component.inputHeight": { + "css": "--vd-component-input-height", + "type": "dimension", + "customizable": true, + "label": "Eingabefeld-Höhe", + "group": "Komponenten", + "min": 34, + "max": 58 + }, + "component.inputRadius": { + "css": "--vd-component-input-radius", + "type": "dimension", + "customizable": true, + "label": "Eingabefeld-Rundung", + "group": "Komponenten", + "min": 0, + "max": 24 + }, + "component.cardRadius": { + "css": "--vd-component-card-radius", + "type": "dimension", + "customizable": true, + "label": "Karten-Rundung", + "group": "Komponenten", + "min": 0, + "max": 32 + }, + "component.cardPadding": { + "css": "--vd-component-card-padding", + "type": "dimension", + "customizable": true, + "label": "Karten-Innenabstand", + "group": "Komponenten", + "min": 8, + "max": 32 + }, + "component.dialogRadius": { + "css": "--vd-component-dialog-radius", + "type": "dimension", + "customizable": true, + "label": "Dialog-Rundung", + "group": "Komponenten", + "min": 0, + "max": 36 + }, + "component.focusWidth": { + "css": "--vd-component-focus-width", + "type": "dimension", + "customizable": true, + "label": "Fokus-Ring Stärke", + "group": "Barrierefreiheit", + "min": 2, + "max": 6 + }, + "component.touchTarget": { + "css": "--vd-component-touch-target", + "type": "dimension", + "customizable": true, + "label": "Mindest-Zielgröße", + "group": "Barrierefreiheit", + "min": 40, + "max": 56 + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-runtime.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-runtime.js new file mode 100644 index 0000000..69c02e7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/theme-runtime.js @@ -0,0 +1,90 @@ +const STORAGE_KEY = 'vendoo-theme-state-v2'; +const LEGACY_KEY = 'vendoo-theme'; +const BUILTIN = new Set(['light', 'dark', 'contrast', 'system']); +let activeCustomProperties = []; + +function mediaDark() { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches; +} + +function mediaReducedMotion() { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +function clearCustomProperties() { + for (const property of activeCustomProperties) document.documentElement.style.removeProperty(property); + activeCustomProperties = []; +} + +function effectiveBuiltin(themeId) { + return themeId === 'system' ? (mediaDark() ? 'dark' : 'light') : themeId; +} + +function normalizeState(input = {}) { + const themeId = String(input.theme_id || input.themeId || 'system'); + return { + theme_id: themeId, + density: ['compact', 'comfortable', 'spacious'].includes(input.density) ? input.density : 'comfortable', + reduced_motion: ['system', 'reduce', 'allow'].includes(input.reduced_motion || input.reducedMotion) ? (input.reduced_motion || input.reducedMotion) : 'system', + custom_theme: input.custom_theme || input.customTheme || null, + }; +} + +function applyState(input, { persist = true, dispatch = true } = {}) { + const state = normalizeState(input); + clearCustomProperties(); + const custom = state.custom_theme && state.custom_theme.id === state.theme_id ? state.custom_theme : null; + const baseTheme = custom ? custom.base_theme : state.theme_id; + const resolved = effectiveBuiltin(BUILTIN.has(baseTheme) ? baseTheme : 'light'); + document.documentElement.dataset.theme = resolved; + document.documentElement.dataset.themePreference = state.theme_id; + document.documentElement.dataset.density = state.density; + const reduce = state.reduced_motion === 'reduce' || (state.reduced_motion === 'system' && mediaReducedMotion()); + document.documentElement.dataset.reducedMotion = reduce ? 'true' : 'false'; + if (custom?.css_values) { + for (const [property, value] of Object.entries(custom.css_values)) { + if (!/^--vd-[a-z0-9-]+$/.test(property)) continue; + document.documentElement.style.setProperty(property, String(value)); + activeCustomProperties.push(property); + } + } + if (persist) localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + if (dispatch) window.dispatchEvent(new CustomEvent('vendoo:theme-changed', { detail: { ...state, resolved } })); + return { ...state, resolved }; +} + +function readStoredState() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (parsed) return normalizeState(parsed); + } catch {} + const legacy = localStorage.getItem(LEGACY_KEY); + return normalizeState({ theme_id: BUILTIN.has(legacy) ? legacy : 'system' }); +} + +function previewTheme({ base_theme = 'light', css_values = {}, density = 'comfortable', reduced_motion = 'system' } = {}) { + return applyState({ theme_id: '__preview__', density, reduced_motion, custom_theme: { id: '__preview__', base_theme, css_values } }, { persist: false }); +} + +function restoreStored() { + return applyState(readStoredState(), { persist: false }); +} + +applyState(readStoredState(), { persist: false, dispatch: false }); +window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', () => { + if (document.documentElement.dataset.themePreference === 'system') restoreStored(); +}); +window.matchMedia?.('(prefers-reduced-motion: reduce)').addEventListener?.('change', () => restoreStored()); + +window.VendooTheme = Object.freeze({ + get preference() { return document.documentElement.dataset.themePreference || 'system'; }, + get resolved() { return document.documentElement.dataset.theme || 'light'; }, + applyState, + previewTheme, + restoreStored, + setTheme(themeId, { persist = true } = {}) { + const current = readStoredState(); + return applyState({ ...current, theme_id: BUILTIN.has(themeId) ? themeId : 'system', custom_theme: null }, { persist }); + }, + supported: Object.freeze([...BUILTIN]), +}); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens.css new file mode 100644 index 0000000..6e44884 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens.css @@ -0,0 +1,140 @@ +/* Automatisch generiert aus design-system/tokens/source.json. Nicht direkt bearbeiten. */ +/* Contract 1.1.0 */ +:root { + --vd-canvas: #f7f5f1; + --vd-surface: #fffdf9; + --vd-surface-raised: #ffffff; + --vd-surface-muted: #f1efea; + --vd-surface-quiet: #ebe8e1; + --vd-line: #e2ded6; + --vd-line-strong: #cbc5ba; + --vd-ink: #171714; + --vd-ink-secondary: #5f5b54; + --vd-ink-muted: #8c877e; + --vd-brand: #e33a16; + --vd-brand-strong: #c92f10; + --vd-brand-soft: rgba(227, 58, 22, 0.08); + --vd-success: #2f7d50; + --vd-success-soft: #e7f2ea; + --vd-warning: #b96a23; + --vd-warning-soft: #fbefe3; + --vd-danger: #c44234; + --vd-danger-soft: #f9e8e5; + --vd-info: #3975ba; + --vd-info-soft: #e8f0f9; + --vd-focus: rgba(227, 58, 22, 0.18); + --vd-shadow-float: 0 18px 48px rgba(35, 30, 24, 0.12); + --vd-shadow-soft: 0 8px 24px rgba(35, 30, 24, 0.06); + --vd-radius-xs: 5px; + --vd-radius-sm: 8px; + --vd-radius-md: 12px; + --vd-sidebar-width: 148px; + --vd-topbar-height: 60px; + --vd-control-height: 40px; + --vd-content-gap: 16px; + --vd-motion-fast: 120ms; + --vd-motion-normal: 180ms; + --vd-component-button-height: 40px; + --vd-component-button-radius: 8px; + --vd-component-input-height: 42px; + --vd-component-input-radius: 8px; + --vd-component-card-radius: 12px; + --vd-component-card-padding: 16px; + --vd-component-dialog-radius: 16px; + --vd-component-focus-width: 3px; + --vd-component-touch-target: 44px; +} + +[data-theme="dark"] { + --vd-canvas: #171714; + --vd-surface: #1d1d19; + --vd-surface-raised: #22221e; + --vd-surface-muted: #292923; + --vd-surface-quiet: #303029; + --vd-line: #37372f; + --vd-line-strong: #4a4940; + --vd-ink: #f2efe8; + --vd-ink-secondary: #c3beb4; + --vd-ink-muted: #8f8a80; + --vd-brand: #ff5a32; + --vd-brand-strong: #ff744f; + --vd-brand-soft: rgba(255, 90, 50, 0.12); + --vd-success: #75bd8c; + --vd-success-soft: #203629; + --vd-warning: #e2a35f; + --vd-warning-soft: #3b2e20; + --vd-danger: #ed7568; + --vd-danger-soft: #3b2523; + --vd-info: #78a9df; + --vd-info-soft: #202f40; + --vd-focus: rgba(255, 90, 50, 0.26); + --vd-shadow-float: 0 20px 56px rgba(0, 0, 0, 0.38); + --vd-shadow-soft: 0 8px 26px rgba(0, 0, 0, 0.20); + --vd-radius-xs: 5px; + --vd-radius-sm: 8px; + --vd-radius-md: 12px; + --vd-sidebar-width: 148px; + --vd-topbar-height: 60px; + --vd-control-height: 40px; + --vd-content-gap: 16px; + --vd-motion-fast: 120ms; + --vd-motion-normal: 180ms; + --vd-component-button-height: 40px; + --vd-component-button-radius: 8px; + --vd-component-input-height: 42px; + --vd-component-input-radius: 8px; + --vd-component-card-radius: 12px; + --vd-component-card-padding: 16px; + --vd-component-dialog-radius: 16px; + --vd-component-focus-width: 3px; + --vd-component-touch-target: 44px; +} + +[data-theme="contrast"] { + --vd-canvas: #000000; + --vd-surface: #090909; + --vd-surface-raised: #111111; + --vd-surface-muted: #1b1b1b; + --vd-surface-quiet: #242424; + --vd-line: #ffffff; + --vd-line-strong: #ffffff; + --vd-ink: #ffffff; + --vd-ink-secondary: #ffffff; + --vd-ink-muted: #f2f2f2; + --vd-brand: #ffe600; + --vd-brand-strong: #ffffff; + --vd-brand-soft: rgba(255, 230, 0, 0.20); + --vd-success: #66ff99; + --vd-success-soft: #002d12; + --vd-warning: #ffd000; + --vd-warning-soft: #332a00; + --vd-danger: #ff6b6b; + --vd-danger-soft: #3a0000; + --vd-info: #72c7ff; + --vd-info-soft: #00253d; + --vd-focus: rgba(255, 230, 0, 0.55); + --vd-shadow-float: 0 0 0 2px #ffffff; + --vd-shadow-soft: 0 0 0 1px #ffffff; + --vd-radius-xs: 0px; + --vd-radius-sm: 2px; + --vd-radius-md: 4px; + --vd-sidebar-width: 168px; + --vd-topbar-height: 64px; + --vd-control-height: 44px; + --vd-content-gap: 18px; + --vd-motion-fast: 0ms; + --vd-motion-normal: 0ms; + --vd-component-button-height: 46px; + --vd-component-button-radius: 2px; + --vd-component-input-height: 48px; + --vd-component-input-radius: 2px; + --vd-component-card-radius: 4px; + --vd-component-card-padding: 18px; + --vd-component-dialog-radius: 4px; + --vd-component-focus-width: 4px; + --vd-component-touch-target: 48px; +} + +@media (prefers-reduced-motion: reduce) { + :root { --vd-motion-fast: 0ms; --vd-motion-normal: 0ms; } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens/source.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens/source.json new file mode 100644 index 0000000..f351e87 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/design-system/tokens/source.json @@ -0,0 +1,459 @@ +{ + "schemaVersion": 1, + "contractVersion": "1.1.0", + "themes": [ + "light", + "dark", + "contrast" + ], + "tokens": { + "color.canvas": { + "css": "--vd-canvas", + "type": "color", + "customizable": true, + "light": "#f7f5f1", + "dark": "#171714", + "contrast": "#000000", + "label": "Seitenhintergrund", + "group": "Farben" + }, + "color.surface": { + "css": "--vd-surface", + "type": "color", + "customizable": true, + "light": "#fffdf9", + "dark": "#1d1d19", + "contrast": "#090909", + "label": "Fläche", + "group": "Farben" + }, + "color.surfaceRaised": { + "css": "--vd-surface-raised", + "type": "color", + "customizable": true, + "light": "#ffffff", + "dark": "#22221e", + "contrast": "#111111", + "label": "Erhöhte Fläche", + "group": "Farben" + }, + "color.surfaceMuted": { + "css": "--vd-surface-muted", + "type": "color", + "customizable": true, + "light": "#f1efea", + "dark": "#292923", + "contrast": "#1b1b1b", + "label": "Gedämpfte Fläche", + "group": "Farben" + }, + "color.surfaceQuiet": { + "css": "--vd-surface-quiet", + "type": "color", + "customizable": true, + "light": "#ebe8e1", + "dark": "#303029", + "contrast": "#242424", + "label": "Ruhige Fläche", + "group": "Farben" + }, + "color.line": { + "css": "--vd-line", + "type": "color", + "customizable": true, + "light": "#e2ded6", + "dark": "#37372f", + "contrast": "#ffffff", + "label": "Trennlinie", + "group": "Farben" + }, + "color.lineStrong": { + "css": "--vd-line-strong", + "type": "color", + "customizable": true, + "light": "#cbc5ba", + "dark": "#4a4940", + "contrast": "#ffffff", + "label": "Starke Trennlinie", + "group": "Farben" + }, + "color.ink": { + "css": "--vd-ink", + "type": "color", + "customizable": true, + "light": "#171714", + "dark": "#f2efe8", + "contrast": "#ffffff", + "label": "Primärtext", + "group": "Farben" + }, + "color.inkSecondary": { + "css": "--vd-ink-secondary", + "type": "color", + "customizable": true, + "light": "#5f5b54", + "dark": "#c3beb4", + "contrast": "#ffffff", + "label": "Sekundärtext", + "group": "Farben" + }, + "color.inkMuted": { + "css": "--vd-ink-muted", + "type": "color", + "customizable": true, + "light": "#8c877e", + "dark": "#8f8a80", + "contrast": "#f2f2f2", + "label": "Hinweistext", + "group": "Farben" + }, + "color.brand": { + "css": "--vd-brand", + "type": "color", + "customizable": true, + "light": "#e33a16", + "dark": "#ff5a32", + "contrast": "#ffe600", + "label": "Akzentfarbe", + "group": "Marke" + }, + "color.brandStrong": { + "css": "--vd-brand-strong", + "type": "color", + "customizable": true, + "light": "#c92f10", + "dark": "#ff744f", + "contrast": "#ffffff", + "label": "Akzentfarbe kräftig", + "group": "Marke" + }, + "color.brandSoft": { + "css": "--vd-brand-soft", + "type": "color", + "customizable": false, + "light": "rgba(227, 58, 22, 0.08)", + "dark": "rgba(255, 90, 50, 0.12)", + "contrast": "rgba(255, 230, 0, 0.20)", + "label": "color.brandSoft", + "group": "Color" + }, + "color.success": { + "css": "--vd-success", + "type": "color", + "customizable": true, + "light": "#2f7d50", + "dark": "#75bd8c", + "contrast": "#66ff99", + "label": "Erfolg", + "group": "Status" + }, + "color.successSoft": { + "css": "--vd-success-soft", + "type": "color", + "customizable": false, + "light": "#e7f2ea", + "dark": "#203629", + "contrast": "#002d12", + "label": "color.successSoft", + "group": "Color" + }, + "color.warning": { + "css": "--vd-warning", + "type": "color", + "customizable": true, + "light": "#b96a23", + "dark": "#e2a35f", + "contrast": "#ffd000", + "label": "Warnung", + "group": "Status" + }, + "color.warningSoft": { + "css": "--vd-warning-soft", + "type": "color", + "customizable": false, + "light": "#fbefe3", + "dark": "#3b2e20", + "contrast": "#332a00", + "label": "color.warningSoft", + "group": "Color" + }, + "color.danger": { + "css": "--vd-danger", + "type": "color", + "customizable": true, + "light": "#c44234", + "dark": "#ed7568", + "contrast": "#ff6b6b", + "label": "Gefahr", + "group": "Status" + }, + "color.dangerSoft": { + "css": "--vd-danger-soft", + "type": "color", + "customizable": false, + "light": "#f9e8e5", + "dark": "#3b2523", + "contrast": "#3a0000", + "label": "color.dangerSoft", + "group": "Color" + }, + "color.info": { + "css": "--vd-info", + "type": "color", + "customizable": true, + "light": "#3975ba", + "dark": "#78a9df", + "contrast": "#72c7ff", + "label": "Information", + "group": "Status" + }, + "color.infoSoft": { + "css": "--vd-info-soft", + "type": "color", + "customizable": false, + "light": "#e8f0f9", + "dark": "#202f40", + "contrast": "#00253d", + "label": "color.infoSoft", + "group": "Color" + }, + "color.focus": { + "css": "--vd-focus", + "type": "color", + "customizable": false, + "light": "rgba(227, 58, 22, 0.18)", + "dark": "rgba(255, 90, 50, 0.26)", + "contrast": "rgba(255, 230, 0, 0.55)", + "label": "color.focus", + "group": "Color" + }, + "shadow.float": { + "css": "--vd-shadow-float", + "type": "shadow", + "customizable": false, + "light": "0 18px 48px rgba(35, 30, 24, 0.12)", + "dark": "0 20px 56px rgba(0, 0, 0, 0.38)", + "contrast": "0 0 0 2px #ffffff", + "label": "shadow.float", + "group": "Shadow" + }, + "shadow.soft": { + "css": "--vd-shadow-soft", + "type": "shadow", + "customizable": false, + "light": "0 8px 24px rgba(35, 30, 24, 0.06)", + "dark": "0 8px 26px rgba(0, 0, 0, 0.20)", + "contrast": "0 0 0 1px #ffffff", + "label": "shadow.soft", + "group": "Shadow" + }, + "radius.xs": { + "css": "--vd-radius-xs", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 16, + "light": "5px", + "dark": "5px", + "contrast": "0px", + "label": "Rundung klein", + "group": "Form" + }, + "radius.sm": { + "css": "--vd-radius-sm", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 20, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Rundung mittel", + "group": "Form" + }, + "radius.md": { + "css": "--vd-radius-md", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 28, + "light": "12px", + "dark": "12px", + "contrast": "4px", + "label": "Rundung groß", + "group": "Form" + }, + "layout.sidebarWidth": { + "css": "--vd-sidebar-width", + "type": "dimension", + "customizable": false, + "light": "148px", + "dark": "148px", + "contrast": "168px", + "label": "layout.sidebarWidth", + "group": "Layout" + }, + "layout.topbarHeight": { + "css": "--vd-topbar-height", + "type": "dimension", + "customizable": false, + "light": "60px", + "dark": "60px", + "contrast": "64px", + "label": "layout.topbarHeight", + "group": "Layout" + }, + "density.controlHeight": { + "css": "--vd-control-height", + "type": "dimension", + "customizable": true, + "min": 32, + "max": 52, + "light": "40px", + "dark": "40px", + "contrast": "44px", + "label": "Bedienelementhöhe", + "group": "Dichte" + }, + "density.contentGap": { + "css": "--vd-content-gap", + "type": "dimension", + "customizable": true, + "min": 8, + "max": 32, + "light": "16px", + "dark": "16px", + "contrast": "18px", + "label": "Inhaltsabstand", + "group": "Dichte" + }, + "motion.fast": { + "css": "--vd-motion-fast", + "type": "duration", + "customizable": false, + "light": "120ms", + "dark": "120ms", + "contrast": "0ms", + "label": "motion.fast", + "group": "Motion" + }, + "motion.normal": { + "css": "--vd-motion-normal", + "type": "duration", + "customizable": false, + "light": "180ms", + "dark": "180ms", + "contrast": "0ms", + "label": "motion.normal", + "group": "Motion" + }, + "component.buttonHeight": { + "css": "--vd-component-button-height", + "type": "dimension", + "customizable": true, + "min": 32, + "max": 56, + "light": "40px", + "dark": "40px", + "contrast": "46px", + "label": "Button-Höhe", + "group": "Komponenten" + }, + "component.buttonRadius": { + "css": "--vd-component-button-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 24, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Button-Rundung", + "group": "Komponenten" + }, + "component.inputHeight": { + "css": "--vd-component-input-height", + "type": "dimension", + "customizable": true, + "min": 34, + "max": 58, + "light": "42px", + "dark": "42px", + "contrast": "48px", + "label": "Eingabefeld-Höhe", + "group": "Komponenten" + }, + "component.inputRadius": { + "css": "--vd-component-input-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 24, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Eingabefeld-Rundung", + "group": "Komponenten" + }, + "component.cardRadius": { + "css": "--vd-component-card-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 32, + "light": "12px", + "dark": "12px", + "contrast": "4px", + "label": "Karten-Rundung", + "group": "Komponenten" + }, + "component.cardPadding": { + "css": "--vd-component-card-padding", + "type": "dimension", + "customizable": true, + "min": 8, + "max": 32, + "light": "16px", + "dark": "16px", + "contrast": "18px", + "label": "Karten-Innenabstand", + "group": "Komponenten" + }, + "component.dialogRadius": { + "css": "--vd-component-dialog-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 36, + "light": "16px", + "dark": "16px", + "contrast": "4px", + "label": "Dialog-Rundung", + "group": "Komponenten" + }, + "component.focusWidth": { + "css": "--vd-component-focus-width", + "type": "dimension", + "customizable": true, + "min": 2, + "max": 6, + "light": "3px", + "dark": "3px", + "contrast": "4px", + "label": "Fokus-Ring Stärke", + "group": "Barrierefreiheit" + }, + "component.touchTarget": { + "css": "--vd-component-touch-target", + "type": "dimension", + "customizable": true, + "min": 40, + "max": 56, + "light": "44px", + "dark": "44px", + "contrast": "48px", + "label": "Mindest-Zielgröße", + "group": "Barrierefreiheit" + } + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/favicon.ico b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/favicon.ico differ diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/index.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/index.html new file mode 100644 index 0000000..c324adb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/index.html @@ -0,0 +1,2329 @@ + + + + + + + Vendoo + + + + + + + + + + + + + + + + +
                          +
                          +
                          + + +
                          +

                          Dashboard

                          + Analytics +
                          +
                          + + + +
                          + + + +
                          +
                          + + +
                          + +
                          +
                          +
                          +

                          Business Intelligence

                          +

                          Dashboard

                          +

                          Dein Überblick über Umsatz, Artikel, Lagerbestand und Performance.

                          +
                          +
                          + + +
                          +
                          + +
                          +
                          +
                          +
                          Umsatz heuteAktueller Tageswert
                          +
                          Verkäufe 7 TageAbgeschlossene Verkäufe
                          +
                          Aktive ArtikelAktuell im Bestand
                          +
                          EntwürfeNoch nicht veröffentlicht
                          +
                          LagerwertGeschätzter Gesamtwert
                          +
                          Publish-ErfolgsquoteErfolgreiche Versuche
                          +
                          + +
                          +
                          +
                          Umsatzentwicklung
                          Letzte 30 Tage
                          +
                          +
                          +
                          +
                          Umsatz nach Plattform
                          +
                          +
                          +
                          +
                          Umsatz nach KategorieVerteilung
                          +
                          +
                          +
                          + +
                          +
                          Heute im FokusDirekt handlungsrelevant
                          +
                          +
                          + +
                          +
                          +
                          Top Seller / BestsellerNach Umsatz
                          +
                          ArtikelSKUPlattformUmsatzVerkäufeStatus
                          +
                          +
                          +
                          SchnellaktionenHäufige Aufgaben
                          +
                          + + + + + +
                          +
                          +
                          +
                          + + +
                          +
                          + + +
                          +
                          +
                          +

                          Commerce Workbench

                          +

                          Studio Flow

                          +

                          Deine nächsten Schritte – klar priorisiert und ohne Kartenüberladung.

                          +
                          +
                          + +
                          + + + + + +
                          + +
                          +
                          + + + + + +
                          + +
                          + +
                          +
                          +
                          +

                          Bereit für den nächsten Schritt

                          0
                          +
                          +
                          + +
                          +
                          +

                          Geplant & in Warteschlange

                          0
                          +
                          ArtikelPlattformGeplant amStatusAktion
                          +
                          +
                          +
                          !

                          Benötigt Aufmerksamkeit

                          0
                          +
                          + +
                          +
                          + +
                          +

                          Aktivität

                          +

                          Letzte Aktivitäten

                          Abgeschlossene Aktionen

                          +
                          +
                          + + +
                          +
                          + + +
                          +
                          +
                          +

                          Listing Studio

                          +

                          Neuer Artikel

                          +

                          Fotos erfassen, AI gezielt steuern und das Ergebnis ohne Kontextwechsel fertigstellen.

                          +
                          +
                          +
                          + 1 Fotos + 2 AI + 3 Prüfen +
                          + +
                          +
                          + +
                          +
                          +
                          +
                          + 01 +
                          +

                          Fotos & Medien

                          +

                          Das erste Bild wird als Cover verwendet.

                          +
                          +
                          + 0 / 10 +
                          + +
                          +
                          +
                          + +
                          + Noch keine Produktfotos + Gute Bilder erhöhen Erkennungsqualität und Verkaufschance. +
                          + + +
                          + +
                          + +
                          + +
                          +

                          Fotos hierher ziehen
                          oder klicken zum Auswählen

                          + JPG, PNG, WEBP oder GIF · maximal 10 Bilder +
                          + + + +
                          + + + + +
                          +
                          +
                          + AI +
                          +

                          Produktbild-Werkzeuge

                          +

                          Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus dem vorhandenen Produktfoto. Freie Prompt-Bilder findest du im separaten FLUX Studio.

                          +
                          +
                          + Safe +
                          +
                          + + + + + +
                          +
                          Wartet auf Produktfotos.
                          + +
                          + +
                          +
                          +
                          + + Foto-Check +
                          + 0 / 5 +
                          +
                            +
                          • Mindestens drei Perspektiven
                          • +
                          • Klares Coverfoto
                          • +
                          • Detail- und Etikettfoto
                          • +
                          • Ruhiger Hintergrund empfohlen
                          • +
                          • Reihenfolge per Drag & Drop
                          • +
                          +
                          +
                          + +
                          +
                          +
                          + 02 +
                          +

                          AI-Generator

                          +

                          Lege Ziel, Sprache und Tonalität fest.

                          +
                          +
                          + Assistiert +
                          + +
                          +
                          +
                          + + Optimiert Aufbau, Ton und Pflichtfelder. +
                          +
                          +
                          + +
                          +
                          + + Wähle den bevorzugten Analyse-Dienst. +
                          +
                          +
                          + + + +
                          +
                          +
                          + +
                          + +
                          +
                          +
                          + +
                          + +
                          +
                          + +
                          +
                          + + Übernimmt Plattform, Sprache, Hinweise und Standard-Tags. +
                          + + +
                          + +
                          +
                          +
                          + + Nur relevante Besonderheiten für diesen Artikel. +
                          + 0 / 500 +
                          + +
                          + Ton: freundlich & präzise + Detailgrad: hoch + Keine Übertreibungen +
                          +
                          +
                          + + + +
                          +
                          + +
                          + Fotos fehlen + Lade mindestens ein Produktfoto hoch. +
                          +
                          + +
                          +
                          + + +
                          +
                          + + + +
                          +
                          +
                          +

                          Listing-Verwaltung

                          +

                          Artikel

                          +

                          Überblick über alle erstellten, geplanten und veröffentlichten Artikel.

                          +
                          +
                          +
                          + + +
                          +
                          + + +
                          +
                          +
                          + +
                          + + + + + + +
                          + +
                          +
                          + +
                          + + + + + + + +
                          + +
                          + +
                          +
                          +
                          Auswahl
                          +
                          Artikel
                          +
                          Plattform
                          +
                          AI-Provider
                          +
                          Preis
                          +
                          Status
                          +
                          Lagerort
                          +
                          Veröffentlicht
                          +
                          Aktionen
                          +
                          +
                          +
                          + + +
                          + + + + + + +
                          + + +
                          +
                          +

                          Listing-Vorlagen

                          + +
                          +
                          + +
                          + + +
                          +
                          +

                          Artikel Quality Center

                          Qualitätscenter

                          Prüfe Inhalte, Bilder, Pflichtfelder, mögliche Duplikate und Plattformtauglichkeit vor dem Veröffentlichen.

                          +
                          +
                          + +
                          +
                          DurchschnittQualitätsscore
                          +
                          Bereitohne Blocker
                          +
                          Blockiertharte Fehler
                          +
                          Artikelim Bestand
                          +
                          + +
                          + + + + +
                          + +
                          +
                          +

                          Prüfliste

                          Artikelqualität

                          0 Artikel
                          +

                          Qualitätsdaten werden geladen …

                          +
                          Seite 1 von 1
                          +
                          + + +
                          +
                          + +
                          +
                          +
                          +

                          Commerce Control

                          +

                          Publish-Center

                          +

                          Bereite Artikel für den Verkauf vor und veröffentliche sie sicher auf deinen Marktplätzen.

                          +
                          +
                          + + +
                          +
                          + + + +
                          + + +
                          +
                          +
                          + + +
                          + + +
                          + + +
                          +
                          +

                          Papierkorb

                          +
                          + Einträge werden nach 30 Tagen automatisch gelöscht + +
                          +
                          +
                          +
                          + + +
                          +
                          +

                          Lagerverwaltung

                          +
                          + +
                          +
                          +
                          +
                          + +
                          + + +
                          +
                          +

                          Verkäufererlös

                          Gebührenrechner

                          Aktuelle Orientierung für private Verkäufer in Deutschland. Etsy wird als Shop-Plattform separat kalkuliert; optionale Werbung, Zusatzleistungen, Steuern und internationale Sonderfälle sind nicht pauschal eingerechnet.

                          + Stand 08.07.2026 +
                          +
                          + + +
                          +
                          +
                          + + +
                          +
                          +
                          +

                          Benutzer & Sicherheit

                          + +
                          + +
                          + + + + + + + +
                          + + +
                          +
                          +
                          + + +
                          +
                          +

                          RBAC

                          Rollen und Berechtigungen

                          Verbindliche Rollenprofile für Artikel, Medien, Publishing und Administration.

                          +
                          +
                          Rollen werden geladen …
                          +
                          +
                          + + +
                          +
                          +
                          + + +
                          +
                          +
                          + + Lade... +
                          +
                          + + +
                          +
                          + + +
                          +
                          + + +
                          +
                          + +
                          + + +
                          +
                          +
                          + + + E-Mail-Adresse, die als Absender angezeigt wird +
                          +
                          + + +
                          +
                          +
                          + + +
                          +
                          +
                          +

                          Runtime & Integrität

                          +

                          Systemprüfung

                          +

                          Prüft Datenbank, Schreibrechte, UI-Dateien, Browser-Extensions und die optionale ComfyUI-Verbindung – ohne API-Schlüssel offenzulegen.

                          +
                          +
                          + + +
                          +
                          +
                          Noch keine Systemprüfung ausgeführt.
                          +
                          +
                          +
                          + +
                          +
                          +
                          +

                          Sicherung, Updates & Browser

                          Betriebszentrale

                          Verifizierte Backups, sichere Wiederherstellung, Update-Staging und Extension-Diagnose an einem Ort.

                          + +
                          + + + +
                          + + + +
                          + +
                          +
                          +
                          +

                          Neues Backup

                          Datenbank wird als konsistenter SQLite-Snapshot gesichert.

                          + + + + +
                          + +
                          +

                          Automatische Sicherung

                          Ein tägliches rotierendes Backup, solange Vendoo läuft.

                          + +
                          + + +
                          + + +
                          + +
                          +

                          Wiederherstellung vorbereiten

                          Das Backup wird vollständig geprüft. Vorher entsteht automatisch eine Sicherheitssicherung.

                          + + +
                          Keine Datei ausgewählt.
                          + +
                          +
                          + +

                          Vorhandene Backups

                          Backups werden geladen …

                          +
                          Backups werden geladen …
                          +
                          + +
                          +
                          +
                          +

                          Installierter Stand

                          Version, Migrationen und letzte Systemoperation.

                          +
                          Vendoo —Wird geladen …
                          +
                          +
                          +
                          +

                          Online-Updateprüfung

                          Optional über eine selbst konfigurierte öffentliche Manifest-URL.

                          + +
                          +
                          Noch nicht geprüft.
                          +
                          +
                          +

                          Update-Paket vorbereiten

                          Prüft Version, Pflichtdateien und verbotene Laufzeitdaten. Vorher wird automatisch gesichert.

                          + + +
                          Keine Datei ausgewählt.
                          + +
                          +
                          +
                          Bewusster Neustart statt riskanter Live-Aktualisierung

                          Vorbereitete Updates und Wiederherstellungen werden erst bei beendetem Vendoo über Setup-Menüpunkt 11 angewendet. Menüpunkt 12 stellt den vorherigen Programmstand wieder her.

                          +
                          + +
                          +
                          Extension-Diagnose wird geladen …
                          +
                          +

                          Zuletzt verbundene Browser

                          Live gilt eine Verbindung, wenn sie sich in den letzten drei Minuten gemeldet hat.

                          +
                          Noch keine Extension-Verbindung gemeldet.
                          +
                          +
                          +
                          + + +
                          +
                          +

                          Serverbetrieb

                          Sicherheitszentrale

                          Bind-Adresse, HTTPS, erlaubte Hosts, Sitzungen, Rate Limits und aktive Bearbeitungssperren.

                          + +
                          +
                          +
                          +
                          +

                          Netzwerk & HTTPS

                          +

                          Sessions & Limits

                          +
                          +

                          Aktive Bearbeitungssperren

                          Verhindert, dass zwei Benutzer denselben Artikel gleichzeitig überschreiben.

                          +
                          Keine aktiven Sperren.
                          +
                          +

                          Empfohlene Server-Konfiguration

                          +
                          VENDOO_BIND_HOST=127.0.0.1
                          +VENDOO_ALLOWED_HOSTS=vendoo.example.de
                          +VENDOO_ALLOWED_ORIGINS=https://vendoo.example.de
                          +VENDOO_TRUST_PROXY=true
                          +PUBLIC_BASE_URL=https://vendoo.example.de
                          +

                          Für LAN-Zugriff VENDOO_BIND_HOST=0.0.0.0 verwenden. Öffentlichen Zugriff ausschließlich hinter einem HTTPS-Reverse-Proxy freigeben.

                          +
                          +
                          + + +
                          +
                          + + + + + + + +
                          +
                          +
                          +
                          Seite 1
                          +
                          +
                          + + + + + + + + + +
                          + + +
                          +
                          +
                          +

                          Lokale Bildgenerierung · Studio Pro

                          +

                          FLUX Studio Pro

                          +

                          Erzeuge 1, 2 oder 4 Prompt-Varianten, verwalte echte ComfyUI-Aufträge und greife jederzeit auf Verlauf, Parameter und Favoriten zurück.

                          +
                          +
                          + Wird geprüft + + + + +
                          +
                          + +
                          +
                          +
                          +
                          01

                          Prompt & Auftrag

                          Reiner Text-zu-Bild-Workflow
                          + +
                          +
                          + + + + + +
                          + +
                          +
                          + Prompt-Check

                          Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.

                          +
                          + +
                          +
                          + Bildvarianten +
                          + + + +
                          + +
                          + +
                          + +
                          + + + + +
                          + +
                          + + + + + +
                          +
                          + + Bereit. +
                          + +
                          + +
                          +
                          02

                          System & ComfyUI

                          +
                          Systemdaten werden geladen …
                          +
                          +
                          + +
                          +
                          +
                          03

                          Auswahl & Varianten

                          Noch kein Bild
                          + +
                          +
                          Dein FLUX-Bild erscheint hierPrompt eingeben und Auftrag starten.
                          + +
                          + +
                          + +
                          +
                          + +
                          +
                          +

                          Produktionszentrale

                          FLUX Job-Center

                          Aktive Aufträge, Verlauf und Archiv mit kontrollierter Bereinigung.

                          +
                          +
                          + + +
                          + + + + + + +
                          +
                          +
                          +
                          +
                          + +
                          + + + + +
                          +
                          +
                          Aufträge werden geladen …
                          +
                          + + Seite 1 von 1 + +
                          +
                          +
                          + +
                          +
                          +

                          Bildbibliothek

                          Ergebnisse & Favoriten

                          +
                          +
                          +
                          Noch keine FLUX-Bilder gespeichert.
                          +
                          Seite 1 von 1
                          +
                          +
                          + +
                          +
                          +

                          Zentrale Medienverwaltung

                          Bildergalerie

                          FLUX-, AI-, bearbeitete und in Artikeln verwendete Bilder gemeinsam durchsuchen, prüfen und verwalten.

                          +
                          + + +
                          +
                          + +
                          + + + + + + +
                          + +
                          + + + + + +
                          +
                          + +
                          + +
                          +
                          + +
                          +
                          0 Bilder
                          +
                          + + + von 1 + +
                          +
                          +
                          Bildergalerie wird geladen …
                          +
                          +
                          0 Bilder
                          +
                          + + + von 1 + +
                          +
                          +
                          + + +
                          +
                          +

                          Batch Image Factory

                          Bildproduktion

                          Erzeuge aus mehreren Quellbildern automatisch optimierte Varianten für Marktplätze, Webshops und Social Media.

                          +
                          +
                          + +
                          +
                          +

                          Auftrag

                          Quellen und Ausgabeprofile

                          0 Bilder · 0 Profile
                          + + +
                          QuellbilderAus der zentralen Bildergalerie auswählen
                          +
                          Bilder werden geladen …
                          + +
                          ProduktionsprofileEin Auftrag kann mehrere Ausgaben pro Bild erzeugen
                          +
                          Profile werden geladen …
                          + +
                          + Zusätzliche Verarbeitung +
                          + + + + + + +
                          +
                          +
                          Noch keine Ausgabe geplantOriginalbilder bleiben unverändert. Jede Ausgabe wird als neue Version gespeichert.
                          +
                          + + +
                          + +
                          +

                          Job-Center

                          Produktionsaufträge

                          +
                          Noch keine Produktionsaufträge.
                          +
                          Seite 1 von 1
                          +
                          +
                          + + + +
                          +
                          +

                          Browser-Integration

                          Vendoo Link Extensions

                          Bereite die passende Browser-Erweiterung vor, öffne den Installationsordner und folge der kurzen Anleitung. Der Browser verlangt aus Sicherheitsgründen immer eine abschließende Bestätigung.

                          +
                          +
                          Aktiver BrowserWird erkannt …
                          + +
                          +
                          +
                          Sicher statt versteckt

                          Vendoo installiert keine Erweiterung unbemerkt. Ein Klick bereitet alles automatisch vor; die letzte Freigabe erfolgt bewusst in Chrome, Edge, Firefox oder Safari.

                          +
                          +
                          +
                          So funktioniert die Ein-Klick-Vorbereitung

                          Vendoo erkennt deinen Browser, öffnet den richtigen Erweiterungsordner und kopiert den Pfad. Danach musst du im Browser nur noch „Entpackte Erweiterung laden“ beziehungsweise die passende Firefox-/Safari-Aktion bestätigen.

                          +
                          1. Bei deinem Browser auf Installation vorbereiten klicken.
                          2. Die geöffnete Erweiterungsverwaltung verwenden.
                          3. Den bereits kopierten Ordner auswählen und Vendoo Link anheften.
                          +
                          +
                          + +
                          +
                          +
                          + Systemzentrale +

                          Einstellungen

                          +

                          Sauber getrennte Bereiche für Grundeinstellungen, AI-Dienste, Marktplätze und den lokalen FLUX-Bildserver.

                          +
                          +
                          +
                          + +
                          AI-SystemWird geprüft …
                          +
                          +
                          + +
                          Lokales FLUXWird geprüft …
                          +
                          +
                          +
                          + +
                          + + +
                          +
                          +
                          +
                          01

                          Allgemeine Einstellungen

                          Die Standards, mit denen Vendoo im Alltag startet.

                          +
                          +
                          +
                          +
                          ArbeitsstandardVorauswahl im Generator
                          +
                          +
                          +
                          +
                          +
                          +
                          + +
                          +
                          Netzwerk & SmartphoneQR-Upload im lokalen Netz
                          +
                          + +
                          + Smartphone und Vendoo-PC müssen im selben Netzwerk sein. +
                          Noch keine LAN-Adresse geprüft.
                          +
                          +
                          +
                          + +
                          +
                          VerkäufernotizenAutomatischer Zusatz für neue Listing-Texte
                          +
                          Wird automatisch in jede Listing-Beschreibung eingebaut.
                          +
                          +
                          + + +
                          +
                          02

                          Theme Manager

                          Darstellung sicher über Design Tokens anpassen – ohne freies CSS und ohne Dateibearbeitung.

                          + +
                          +
                          Meine DarstellungPersönliches Theme, Dichte und Bewegung
                          Live-Vorschau
                          +
                          +
                          +
                          +
                          + +
                          +
                          + +
                          +
                          SystemstandardVorgabe für neue Benutzer und „Systemstandard“
                          +
                          +
                          +
                          +
                          +
                          +
                          + +
                          +
                          +
                          Neues ThemeNur freigegebene Tokenwerte – kein CSS oder JavaScript
                          +
                          +
                          +
                          +
                          +
                          +
                          +
                          + + +
                          + +
                          Theme Manager wird geladen …
                          +
                          + +
                          +
                          02

                          AI & API-Dienste

                          Textanalyse, Bildprovider und die Genauigkeit der Produktreferenz.

                          + +
                          +
                          API-ZugängeKeys bleiben lokal in deiner Vendoo-Installation
                          +
                          +
                          Claude für Listing-Texte.
                          +
                          Analyse, Moderation und Bild-Fallback.
                          +
                          Optionale kostenlose Bildmodelle.
                          +
                          +
                          Lokale Text-AI via Ollama.
                          +
                          + + +
                          +
                          Lokale BildwerkzeugeGhost Mannequin, Flatlay und optionale Cloud-Fallbacks
                          +
                          +
                          +
                          +
                          +
                          +
                          768 ist deutlich schneller und für Marktplatzbilder meist ausreichend.
                          +
                          + +
                          +
                          Produktfotos verbindlich verwendenDiese Referenzsteuerung gilt für Ghost Mannequin und Flatlay. Der freie FLUX-Generator arbeitet unabhängig davon nur mit Texteingaben.
                          + +
                          70%
                          +
                          + +
                          + + + + +
                          + +
                          + Cloud-Bildprovider konfigurierenOpenRouter und OpenAI +
                          +
                          Leer lassen für automatische Auswahl.
                          +
                          +
                          Noch keine OpenRouter-Modelle geladen.
                          + +
                          +
                          +
                          +
                          + +
                          +
                          03

                          Marktplätze

                          API-Verbindungen für Etsy und eBay übersichtlich an einem Ort.

                          +
                          +
                          +
                          EtsyAPI-Key und Verbindung
                          +
                          Key unter etsy.com/developers erstellen.
                          + +
                          + +
                          +
                          eBay.deDeveloper-Zugang und OAuth
                          +
                          +
                          +
                          + + +
                          +
                          +
                          + +
                          +
                          04

                          Lokales FLUX / ComfyUI

                          Installation, Aktivierung, Serverstatus und Wartung ohne Technik-Chaos.

                          + +
                          +
                          +
                          FLUX
                          +
                          Lokale Bild-Engine

                          Status wird geprüft …

                          Vendoo prüft Installation, Modell und laufenden Bildserver.

                          +
                          + +
                          + + + + +
                          +
                          + +
                          +
                          InstallationWird geprüft
                          +
                          FLUX-ModellWird geprüft
                          +
                          BildserverWird geprüft
                          +
                          Prompt-ModusOhne Produktfoto
                          +
                          + +
                          +
                          Leistung & StandardausgabeOptimiert Startverhalten und neue FLUX-Aufträge.
                          +
                          +
                          +
                          +
                          +
                          +
                          +
                          +
                          + + +
                          HinweisDas Serverprofil wird beim nächsten FLUX-Start aktiv. Schnell hält Modelle bevorzugt im VRAM; Wenig VRAM spart Speicher, ist aber langsamer.
                          +
                          + +
                          +
                          Installation & WartungNur die Aktion wählen – Vendoo erledigt den Rest.
                          Wird geprüft
                          +
                          + + + +
                          +
                          + + + +
                          +
                          Lokaler FLUX/ComfyUI-Status wird geprüft.
                          +
                          + +
                          +
                          Technischer InstallationsstatusNoch nicht gestartet
                          +
                          Installation0%
                          +
                          InstallerBereit
                          Laufzeit0 s
                          Entpackt0 Dateien
                          Datenmenge0 MB
                          Heartbeat
                          +
                          + +
                          Installationsprotokoll anzeigen
                          Noch kein Installationsprotokoll vorhanden.
                          +
                          7-Zip-Ausgabe anzeigen
                          Noch keine 7-Zip-Ausgabe vorhanden.
                          +
                          + +
                          + Erweiterte lokale KonfigurationURL, Token und Pfade +
                          +
                          +
                          +
                          +
                          + +
                          +
                          +
                          +
                          +
                          + +
                          +
                          Änderungen werden lokal gespeichertKonfigurationen gelten direkt nach dem Speichern.
                          + +
                          +
                          +
                          + + + + + + + + + + + + + + + + + + +
                          + + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/login.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/login.html new file mode 100644 index 0000000..f8d6a93 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/login.html @@ -0,0 +1,263 @@ + + + + + + Vendoo — Login + + + + + + + + + + + + + + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload-expired.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload-expired.html new file mode 100644 index 0000000..6ba98a9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload-expired.html @@ -0,0 +1,9 @@ +Upload-Link abgelaufen + + + + + + + +
                          VendooMobiler Foto-Upload
                          !

                          Upload-Link abgelaufen

                          Erzeuge in Vendoo einen neuen QR-Code und scanne ihn erneut.

                          diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.css new file mode 100644 index 0000000..6996f00 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.css @@ -0,0 +1,65 @@ +:root{--bg:#f4f0e8;--surface:#fffdf9;--surface-2:#f9f6f0;--line:#ded7cc;--ink:#24221e;--muted:#777168;--brand:#e63b17;--brand-dark:#c92e0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#be4037;--shadow:0 24px 60px rgba(44,38,30,.12)}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100dvh;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.07),transparent 35%),var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.mobile-upload-shell{width:min(100%,540px);min-height:100dvh;margin:auto;padding:calc(18px + env(safe-area-inset-top)) 16px calc(24px + env(safe-area-inset-bottom));display:grid;align-content:start;gap:14px}.mobile-upload-brand{display:flex;align-items:center;gap:11px;padding:2px 3px 7px}.mobile-upload-brand>div{display:grid;gap:1px}.mobile-upload-brand strong{font-size:19px;letter-spacing:-.04em}.mobile-upload-brand span:not(.mobile-upload-logo){font-size:11px;color:var(--muted)}.mobile-upload-logo{position:relative;width:38px;height:38px;display:block}.mobile-upload-logo i{position:absolute;left:5px;top:5px;width:9px;height:28px;border-radius:6px;background:var(--brand);transform:rotate(-30deg)}.mobile-upload-logo b{position:absolute;left:19px;top:4px;width:15px;height:17px;border-radius:4px 7px 7px 5px;background:var(--brand);transform:rotate(14deg)}.mobile-upload-logo b:after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.mobile-upload-logo em{position:absolute;right:0;bottom:0;width:15px;height:15px;display:grid;place-items:center;border-radius:50%;background:#30302d;color:#fff;font-size:9px;font-style:normal}.mobile-upload-card{padding:20px;border:1px solid var(--line);border-radius:20px;background:rgba(255,253,249,.94);box-shadow:0 10px 35px rgba(44,38,30,.06);backdrop-filter:blur(12px)}.mobile-upload-card.hero{padding:24px}.eyebrow{display:block;margin-bottom:8px;color:var(--brand);font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.mobile-upload-card h1,.mobile-upload-card h2{margin:0;color:var(--ink);font-size:clamp(25px,7vw,34px);line-height:1.04;letter-spacing:-.05em}.mobile-upload-card h2{font-size:24px}.mobile-upload-card p{margin:11px 0 0;color:var(--muted);font-size:14px;line-height:1.55}.session-status{display:flex;align-items:center;gap:8px;margin-top:18px;padding:10px 12px;border-radius:11px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:700}.session-status span{width:8px;height:8px;border-radius:50%;background:#aaa}.session-status.ok{background:var(--success-soft);color:var(--success)}.session-status.ok span{background:var(--success)}.camera-action{min-height:180px;display:grid;place-items:center;align-content:center;gap:7px;padding:20px;border:1.5px dashed #cfc6b8;border-radius:16px;background:linear-gradient(145deg,#fff,var(--surface-2));text-align:center;cursor:pointer}.camera-action:active{border-color:var(--brand);background:var(--brand-soft)}.camera-icon{width:48px;height:48px;display:grid;place-items:center;border-radius:15px;background:var(--brand);color:#fff;font-size:28px;box-shadow:0 12px 25px rgba(230,59,23,.2)}.camera-action strong{font-size:15px}.camera-action small{color:var(--muted);font-size:11px}.upload-meta{display:flex;justify-content:space-between;gap:12px;margin:14px 2px 9px;color:var(--muted);font-size:11px}.mobile-preview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.mobile-preview figure{position:relative;aspect-ratio:1;margin:0;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}.mobile-preview img{width:100%;height:100%;object-fit:contain}.mobile-preview button{position:absolute;right:5px;top:5px;width:25px;height:25px;border:0;border-radius:50%;background:rgba(28,27,24,.8);color:#fff;font-size:15px}.mobile-primary,.mobile-secondary{width:100%;min-height:50px;margin-top:14px;border-radius:13px;font:inherit;font-size:14px;font-weight:800;cursor:pointer}.mobile-primary{border:0;background:var(--brand);color:#fff;box-shadow:0 14px 28px rgba(230,59,23,.2)}.mobile-primary:disabled{opacity:.4;box-shadow:none}.mobile-primary:not(:disabled):active{background:var(--brand-dark)}.mobile-secondary{border:1px solid var(--line);background:var(--surface);color:var(--ink)}.upload-progress{height:7px;margin-top:13px;overflow:hidden;border-radius:999px;background:#e7e1d8}.upload-progress span{display:block;width:45%;height:100%;border-radius:inherit;background:var(--brand);animation:progress 1.1s ease-in-out infinite alternate}@keyframes progress{to{transform:translateX(122%)}}.success,.error{text-align:center}.success-mark,.error-mark{width:62px;height:62px;display:grid;place-items:center;margin:0 auto 15px;border-radius:20px;font-size:31px;font-weight:900}.success-mark{background:var(--success-soft);color:var(--success)}.error-mark{background:rgba(190,64,55,.1);color:var(--danger)}footer{padding:4px 18px;color:var(--muted);font-size:10px;line-height:1.5;text-align:center}.hidden{display:none!important}@media(min-width:460px){.mobile-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:360px){.mobile-upload-shell{padding-inline:10px}.mobile-upload-card{padding:16px}.mobile-upload-card.hero{padding:19px}.mobile-preview{grid-template-columns:repeat(2,minmax(0,1fr))}} +.mobile-upload-brand-icon{width:40px;height:40px;display:block;object-fit:contain;flex:0 0 auto} + +.mobile-source-actions, +.mobile-more-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.camera-action { + width: 100%; + min-height: 132px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--brand-soft); + color: var(--ink); + padding: 18px 14px; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + text-align: center; + cursor: pointer; + font: inherit; +} + +.camera-action.secondary-source { + background: var(--surface-2); +} + +.camera-action:disabled, +.mobile-secondary:disabled { + opacity: .5; + cursor: not-allowed; +} + +.mobile-preview-fallback { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-content: center; + gap: 4px; + padding: 8px; + text-align: center; + background: var(--surface-2); + color: var(--muted); + font-size: 11px; +} + +.mobile-preview-fallback strong { + color: var(--ink); + font-size: 12px; +} + +@media (max-width: 420px) { + .mobile-source-actions, + .mobile-more-actions { + grid-template-columns: 1fr; + } + + .camera-action { + min-height: 112px; + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.html b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.html new file mode 100644 index 0000000..54152a5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.html @@ -0,0 +1,77 @@ + + + + + + + Vendoo Foto-Upload + + + + + + + + + + + +
                          +
                          + +
                          VendooMobiler Foto-Upload
                          +
                          + +
                          + Direkt vom Smartphone +

                          Produktfotos aufnehmen

                          +

                          Fotografiere den Artikel oder wähle vorhandene Bilder. Sie erscheinen automatisch im geöffneten Vendoo-Studio.

                          +
                          Upload-Link wird geprüft …
                          +
                          + + + + + + + +
                          Die Bilder werden ausschließlich an deine Vendoo-Instanz übertragen.
                          +
                          + + + diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.js new file mode 100644 index 0000000..1ad5b2b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/mobile-upload.js @@ -0,0 +1,169 @@ +(() => { + 'use strict'; + + const token = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || ''); + const cameraInput = document.getElementById('camera-input'); + const libraryInput = document.getElementById('library-input'); + const preview = document.getElementById('mobile-preview'); + const uploadButton = document.getElementById('upload-btn'); + const uploadPanel = document.getElementById('upload-panel'); + const successPanel = document.getElementById('success-panel'); + const errorPanel = document.getElementById('error-panel'); + const status = document.getElementById('session-status'); + const count = document.getElementById('photo-count'); + const expiresCopy = document.getElementById('expires-copy'); + const progress = document.getElementById('upload-progress'); + const sourceButtons = [ + document.getElementById('open-camera-btn'), + document.getElementById('open-library-btn'), + document.getElementById('add-camera-btn'), + document.getElementById('add-library-btn'), + document.getElementById('success-camera-btn'), + document.getElementById('success-library-btn'), + ].filter(Boolean); + + let selected = []; + let serverFiles = []; + let remaining = 10; + + function showError(message) { + uploadPanel.classList.add('hidden'); + successPanel.classList.add('hidden'); + errorPanel.classList.remove('hidden'); + document.getElementById('error-copy').textContent = message; + status.className = 'session-status'; + status.innerHTML = 'Upload nicht verfügbar'; + } + + function openCamera() { + if (remaining - selected.length <= 0) return; + cameraInput.click(); + } + + function openLibrary() { + if (remaining - selected.length <= 0) return; + libraryInput.click(); + } + + function render() { + preview.innerHTML = ''; + selected.forEach((file, index) => { + const figure = document.createElement('figure'); + const image = document.createElement('img'); + image.alt = file.name || `Foto ${index + 1}`; + const objectUrl = URL.createObjectURL(file); + image.src = objectUrl; + image.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true }); + image.addEventListener('error', () => { + URL.revokeObjectURL(objectUrl); + image.remove(); + const fallback = document.createElement('div'); + fallback.className = 'mobile-preview-fallback'; + fallback.innerHTML = `Bild ${index + 1}${file.name || 'Foto ausgewählt'}`; + figure.prepend(fallback); + }, { once: true }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.textContent = '×'; + remove.setAttribute('aria-label', `${file.name || 'Bild'} entfernen`); + remove.addEventListener('click', () => { + selected.splice(index, 1); + render(); + }); + figure.append(image, remove); + preview.appendChild(figure); + }); + + const total = serverFiles.length + selected.length; + count.textContent = `${total} von 10 Bildern`; + uploadButton.disabled = selected.length === 0; + uploadButton.textContent = selected.length + ? `${selected.length} ${selected.length === 1 ? 'Foto' : 'Fotos'} zu Vendoo senden` + : 'Fotos zu Vendoo senden'; + + const noSlots = remaining - selected.length <= 0; + sourceButtons.forEach(button => { button.disabled = noSlots; }); + } + + function addFiles(files) { + const candidates = [...files].filter(file => file.type.startsWith('image/') || /\.(heic|heif)$/i.test(file.name || '')); + const slots = Math.max(0, remaining - selected.length); + selected.push(...candidates.slice(0, slots)); + render(); + } + + async function loadSession() { + try { + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}`, { cache: 'no-store' }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload-Link konnte nicht geladen werden.'); + serverFiles = data.files || []; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + status.className = 'session-status ok'; + status.innerHTML = 'Mit Vendoo verbunden'; + uploadPanel.classList.remove('hidden'); + const expires = new Date(String(data.expires_at).replace(' ', 'T') + (String(data.expires_at).includes('Z') ? '' : 'Z')); + expiresCopy.textContent = Number.isNaN(expires.getTime()) + ? '' + : `gültig bis ${expires.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + render(); + } catch (error) { + showError(error.message); + } + } + + async function upload() { + if (!selected.length) return; + uploadButton.disabled = true; + sourceButtons.forEach(button => { button.disabled = true; }); + progress.classList.remove('hidden'); + try { + const form = new FormData(); + selected.forEach(file => form.append('photos', file, file.name || `mobile-${Date.now()}.jpg`)); + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}/photos?platform=mobile`, { + method: 'POST', + body: form, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen.'); + serverFiles = data.files || serverFiles; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + selected = []; + render(); + uploadPanel.classList.add('hidden'); + successPanel.classList.remove('hidden'); + } catch (error) { + showError(error.message); + } finally { + progress.classList.add('hidden'); + uploadButton.disabled = false; + sourceButtons.forEach(button => { button.disabled = false; }); + } + } + + function returnToUploadAndOpen(opener) { + successPanel.classList.add('hidden'); + uploadPanel.classList.remove('hidden'); + window.setTimeout(opener, 50); + } + + cameraInput.addEventListener('change', () => { + addFiles(cameraInput.files || []); + cameraInput.value = ''; + }); + libraryInput.addEventListener('change', () => { + addFiles(libraryInput.files || []); + libraryInput.value = ''; + }); + + document.getElementById('open-camera-btn').addEventListener('click', openCamera); + document.getElementById('open-library-btn').addEventListener('click', openLibrary); + document.getElementById('add-camera-btn').addEventListener('click', openCamera); + document.getElementById('add-library-btn').addEventListener('click', openLibrary); + document.getElementById('success-camera-btn').addEventListener('click', () => returnToUploadAndOpen(openCamera)); + document.getElementById('success-library-btn').addEventListener('click', () => returnToUploadAndOpen(openLibrary)); + uploadButton.addEventListener('click', upload); + + loadSession(); +})(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/README.md b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/README.md new file mode 100644 index 0000000..5f503fd --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/README.md @@ -0,0 +1,9 @@ +# Frontend-Module + +Neue Frontend-Module werden künftig als ES-Module unter diesem Verzeichnis angelegt und über `FrontendModuleRegistry` mit einem klaren `mount`-/`unmount`-Lebenszyklus betrieben. + +Die Bestandsoberfläche aus `public/app.js` bleibt in 1.35.0 unverändert aktiv. Sie wird in späteren Releases tabweise migriert. Direkte neue globale Event-Listener und ungeprüfte `innerHTML`-Zuweisungen sind für migrierte Module nicht zulässig. + +## Theme Manager 1.36.0 + +`theme-manager/` ist das erste sichtbare native Frontend-Fachmodul. Es nutzt den zentralen API-Client, sichere DOM-Erzeugung, Token-Verträge und einen klaren Vorschau-/Speicher-Lebenszyklus. diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.css new file mode 100644 index 0000000..7ee5c3d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.css @@ -0,0 +1,49 @@ +:root { + --vd-theme-manager-preview-height: 210px; +} +[data-reduced-motion="true"] *, [data-reduced-motion="true"] *::before, [data-reduced-motion="true"] *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; } +[data-density="compact"] { --vd-control-height: 34px; --vd-content-gap: 10px; --vd-component-button-height: 34px; --vd-component-input-height: 36px; --vd-component-card-padding: 12px; } +[data-density="spacious"] { --vd-control-height: 46px; --vd-content-gap: 22px; --vd-component-button-height: 46px; --vd-component-input-height: 48px; --vd-component-card-padding: 22px; } +.vd-card { background: var(--vd-surface-raised); border: 1px solid var(--vd-line); border-radius: var(--vd-component-card-radius); padding: var(--vd-component-card-padding); box-shadow: var(--vd-shadow-soft); } +.vd-button { min-height: var(--vd-component-button-height); border-radius: var(--vd-component-button-radius); } +.setting-group input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), .setting-group select, .vd-input { min-height: var(--vd-component-input-height); border-radius: var(--vd-component-input-radius); } +:where(button, a, input, select, textarea, [tabindex]):focus-visible { outline: var(--vd-component-focus-width) solid var(--vd-brand); outline-offset: 2px; } +.theme-manager-grid { display:grid; gap:16px; } +.theme-choice-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:10px; } +.theme-choice { min-height:104px; display:grid; gap:8px; align-content:space-between; padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); color:var(--vd-ink); text-align:left; cursor:pointer; } +.theme-choice:hover { border-color:var(--vd-brand); } +.theme-choice[aria-pressed="true"] { border-color:var(--vd-brand); box-shadow:0 0 0 3px var(--vd-focus); } +.theme-choice-swatches { display:flex; gap:5px; } +.theme-choice-swatches i { width:22px; height:22px; border-radius:50%; border:1px solid color-mix(in srgb, currentColor 18%, transparent); } +.theme-manager-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:end; } +.theme-manager-toolbar .setting-group { min-width:170px; flex:1 1 170px; } +.theme-editor-layout { display:grid; grid-template-columns:minmax(0,1.3fr) minmax(260px,.7fr); gap:14px; } +.theme-token-groups { display:grid; gap:12px; max-height:620px; overflow:auto; padding-right:4px; } +.theme-token-group { border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); overflow:hidden; } +.theme-token-group > h4 { margin:0; padding:10px 12px; background:var(--vd-surface-muted); font-size:12px; } +.theme-token-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:10px; padding:12px; } +.theme-token-field { display:grid; gap:5px; } +.theme-token-field label { font-size:11px; font-weight:700; } +.theme-color-control { display:grid; grid-template-columns:42px minmax(0,1fr); gap:7px; } +.theme-color-control input[type="color"] { width:42px; height:var(--vd-component-input-height); padding:3px; border:1px solid var(--vd-line); border-radius:var(--vd-component-input-radius); background:var(--vd-surface); } +.theme-preview { position:sticky; top:10px; display:grid; gap:12px; align-content:start; } +.theme-preview-stage { min-height:var(--vd-theme-manager-preview-height); display:grid; gap:10px; padding:16px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-canvas); color:var(--vd-ink); } +.theme-preview-card { padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); } +.theme-preview-actions { display:flex; gap:8px; flex-wrap:wrap; } +.theme-contrast-list { display:grid; gap:6px; } +.theme-contrast-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; padding:7px 9px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); font-size:11px; } +.theme-contrast-row.is-ok strong { color:var(--vd-success); } +.theme-contrast-row.is-fail strong { color:var(--vd-danger); } +.theme-manager-status { min-height:22px; padding:8px 10px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:11px; } +.theme-profile-list { display:grid; gap:8px; } +.theme-profile-row { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:10px; border:1px solid var(--vd-line); border-radius:var(--vd-radius-sm); } +.theme-profile-row > div { min-width:0; } +.theme-profile-row small { color:var(--vd-ink-muted); } +.theme-admin-only.hidden { display:none !important; } +@media(max-width:900px){ .theme-editor-layout{grid-template-columns:1fr}.theme-preview{position:static}.theme-token-groups{max-height:none} } +@media(min-width:901px){ .settings-nav { grid-template-columns:repeat(5,minmax(0,1fr)); } } +.theme-manager-status[data-tone="success"] { color:var(--vd-success); background:var(--vd-success-soft); } +.theme-manager-status[data-tone="warning"] { color:var(--vd-warning); background:var(--vd-warning-soft); } +.theme-manager-status[data-tone="danger"] { color:var(--vd-danger); background:var(--vd-danger-soft); } +.theme-choice > span:last-child { display:grid; gap:2px; } +.theme-choice small { color:var(--vd-ink-muted); } diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.js new file mode 100644 index 0000000..ce6f256 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/modules/theme-manager/theme-manager.js @@ -0,0 +1,328 @@ +import { apiRequest } from '../../core/api-client.js'; + +const root = document.getElementById('theme-manager-root'); +if (!root) throw new Error('Theme Manager Root fehlt.'); + +const state = { + catalog: null, + me: null, + selectedThemeId: 'inherit', + editingId: null, + draftId: null, + editorValues: {}, +}; + +const $ = selector => root.querySelector(selector); +const status = (message, tone = 'info') => { + const node = $('#theme-manager-status'); + node.textContent = message; + node.dataset.tone = tone; +}; + +function cssValues(values = {}) { + const tokens = state.catalog?.contract?.tokens || {}; + return Object.fromEntries(Object.entries(values).flatMap(([name, value]) => tokens[name]?.css ? [[tokens[name].css, value]] : [])); +} + +function profileById(id) { + return [...(state.catalog?.builtin || []), ...(state.catalog?.custom || [])].find(theme => theme.id === id) || null; +} + +function runtimeState(preference, profile = null) { + return { + theme_id: preference.theme_id, + density: preference.density, + reduced_motion: preference.reduced_motion, + custom_theme: profile && !profile.builtin ? { + id: profile.id, + base_theme: profile.base_theme, + css_values: cssValues(profile.resolved_values || profile.values), + } : null, + }; +} + +function applyPreference(preference, { persist = true } = {}) { + const effective = preference.theme_id === 'inherit' ? state.catalog.system_default : preference; + const profile = profileById(effective.theme_id); + window.VendooTheme?.applyState(runtimeState(effective, profile), { persist }); +} + + +function renderChoices() { + const host = $('#theme-choice-grid'); + const choices = [ + { id: 'inherit', name: 'Systemstandard', base_theme: state.catalog.system_default.theme_id, resolved_values: profileById(state.catalog.system_default.theme_id)?.resolved_values || profileById('light').resolved_values, builtin: true }, + { id: 'system', name: 'Betriebssystem', base_theme: 'system', resolved_values: profileById(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light').resolved_values, builtin: true }, + ...state.catalog.builtin.filter(item => item.id !== 'system'), + ...state.catalog.custom, + ]; + host.replaceChildren(...choices.map(profile => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'theme-choice'; + button.dataset.themeId = profile.id; + button.setAttribute('aria-pressed', String(profile.id === state.selectedThemeId)); + const swatchWrap = document.createElement('span'); + swatchWrap.className = 'theme-choice-swatches'; + swatchWrap.setAttribute('aria-hidden', 'true'); + for (const token of ['color.canvas', 'color.surface', 'color.brand', 'color.ink']) { + const swatch = document.createElement('i'); + swatch.style.background = profile.resolved_values?.[token] || '#888888'; + swatchWrap.append(swatch); + } + const copy = document.createElement('span'); + const title = document.createElement('strong'); title.textContent = profile.name; + const meta = document.createElement('small'); meta.textContent = profile.builtin ? 'Vendoo Preset' : 'Eigenes Theme'; + copy.append(title, meta); button.append(swatchWrap, copy); + button.addEventListener('click', () => selectTheme(profile.id)); + return button; + })); +} + +function selectTheme(themeId) { + state.selectedThemeId = themeId; + root.querySelectorAll('.theme-choice').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.themeId === themeId))); + const preference = { + theme_id: themeId, + density: $('#theme-density').value, + reduced_motion: $('#theme-reduced-motion').value, + }; + applyPreference(preference, { persist: false }); + status('Vorschau aktiv. Mit „Meine Darstellung speichern“ dauerhaft übernehmen.'); +} + + +function renderSystemOptions() { + const select = $('#theme-system-default'); + const selected = state.catalog.system_default.theme_id; + [...select.querySelectorAll('option[data-custom="true"]')].forEach(option => option.remove()); + for (const profile of state.catalog.custom) { + const option = document.createElement('option'); + option.value = profile.id; + option.textContent = profile.name; + option.dataset.custom = 'true'; + select.append(option); + } + select.value = selected; +} + +function renderProfiles() { + const list = $('#theme-profile-list'); + list.replaceChildren(...state.catalog.custom.map(profile => { + const row = document.createElement('div'); + row.className = 'theme-profile-row'; + const copy = document.createElement('div'); + const title = document.createElement('strong'); + title.textContent = profile.name; + const meta = document.createElement('small'); + meta.textContent = `Basis: ${profile.base_theme} · ${Object.keys(profile.values || {}).length} Anpassungen`; + copy.append(title, document.createElement('br'), meta); + const actions = document.createElement('div'); + const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'vd-button compact'; edit.textContent = 'Bearbeiten'; edit.addEventListener('click', () => openEditor(profile)); + const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'vd-button compact'; exp.textContent = 'Export'; exp.addEventListener('click', () => exportProfile(profile.id)); + actions.append(edit, exp); row.append(copy, actions); return row; + })); + if (!state.catalog.custom.length) { + const empty = document.createElement('p'); empty.className = 'theme-manager-status'; empty.textContent = 'Noch keine eigenen Themes angelegt.'; list.append(empty); + } +} + +function baseValues(baseTheme) { + return profileById(baseTheme)?.resolved_values || profileById('light')?.resolved_values || {}; +} + +function currentEditorDefinition() { + return { + id: state.editingId || state.draftId || 'custom-draft', + name: $('#theme-editor-name').value.trim(), + base_theme: $('#theme-editor-base').value, + values: { ...state.editorValues }, + }; +} + +function renderTokenFields(profile = null) { + const baseTheme = $('#theme-editor-base').value; + const defaults = baseValues(baseTheme); + const contract = state.catalog.contract.tokens; + const groups = new Map(); + for (const [name, token] of Object.entries(contract)) { + if (!token.customizable) continue; + if (!groups.has(token.group)) groups.set(token.group, []); + groups.get(token.group).push([name, token]); + } + const host = $('#theme-token-groups'); + host.replaceChildren(...[...groups.entries()].map(([groupName, entries]) => { + const section = document.createElement('section'); section.className = 'theme-token-group'; + const heading = document.createElement('h4'); heading.textContent = groupName; section.append(heading); + const list = document.createElement('div'); list.className = 'theme-token-list'; + for (const [name, token] of entries) { + const field = document.createElement('div'); field.className = 'theme-token-field'; + const label = document.createElement('label'); label.htmlFor = `theme-token-${name.replace(/[^a-z0-9]/gi, '-')}`; label.textContent = token.label; + const value = state.editorValues[name] ?? defaults[name] ?? ''; + if (token.type === 'color') { + const wrap = document.createElement('div'); wrap.className = 'theme-color-control'; + const color = document.createElement('input'); color.type = 'color'; color.value = /^#[0-9a-f]{6}$/i.test(value) ? value : '#000000'; color.tabIndex = -1; color.setAttribute('aria-hidden', 'true'); + const text = document.createElement('input'); text.id = label.htmlFor; text.className = 'vd-input'; text.value = value; text.dataset.token = name; + color.addEventListener('input', () => { text.value = color.value; updateEditorValue(name, text.value); }); + text.addEventListener('input', () => { if (/^#[0-9a-f]{6}$/i.test(text.value)) color.value = text.value; updateEditorValue(name, text.value); }); + wrap.append(color, text); field.append(label, wrap); + } else { + const input = document.createElement('input'); input.id = label.htmlFor; input.className = 'vd-input'; input.value = value; input.dataset.token = name; input.inputMode = 'decimal'; + input.addEventListener('input', () => updateEditorValue(name, input.value)); field.append(label, input); + } + list.append(field); + } + section.append(list); return section; + })); + previewEditor(); +} + +function updateEditorValue(name, value) { + const base = baseValues($('#theme-editor-base').value)[name]; + if (String(value).trim() === String(base).trim()) delete state.editorValues[name]; + else state.editorValues[name] = String(value).trim(); + previewEditor(); +} + +function previewEditor() { + const definition = currentEditorDefinition(); + const merged = { ...baseValues(definition.base_theme), ...definition.values }; + window.VendooTheme?.previewTheme({ base_theme: definition.base_theme, css_values: cssValues(merged), density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value }); + $('#theme-preview-name').textContent = definition.name || 'Theme-Vorschau'; +} + + +async function validateEditor(showMessage = true) { + try { + const result = await apiRequest('/api/themes/validate', { method: 'POST', body: currentEditorDefinition() }); + renderContrast(result.accessibility); + if (showMessage) status(result.accessibility.ok ? 'Theme ist gültig und erfüllt die geprüften Kontrastziele.' : 'Theme ist gültig, hat aber Kontrastwarnungen.', result.accessibility.ok ? 'success' : 'warning'); + return result; + } catch (error) { + renderContrast({ checks: [], ok: false }); + if (showMessage) status(error.message, 'danger'); + return null; + } +} + +function renderContrast(accessibility) { + const host = $('#theme-contrast-list'); + host.replaceChildren(...(accessibility?.checks || []).map(check => { + const row = document.createElement('div'); row.className = `theme-contrast-row ${check.ok ? 'is-ok' : 'is-fail'}`; + const label = document.createElement('span'); label.textContent = check.id.replaceAll('-', ' '); + const result = document.createElement('strong'); result.textContent = `${check.ratio ?? '–'} : 1`; + row.append(label, result); return row; + })); +} + +function openEditor(profile = null) { + state.editingId = profile?.id || null; + state.draftId = profile?.id || `custom-${Date.now().toString(36)}`; + state.editorValues = { ...(profile?.values || {}) }; + $('#theme-editor-name').value = profile?.name || ''; + $('#theme-editor-base').value = profile?.base_theme || 'light'; + $('#theme-delete-btn').hidden = !profile; + $('#theme-editor-title').textContent = profile ? 'Theme bearbeiten' : 'Neues Theme'; + renderTokenFields(profile); + $('#theme-editor-name').focus(); +} + +async function savePreference() { + const payload = { theme_id: state.selectedThemeId, density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value }; + try { + const saved = await apiRequest('/api/themes/preference', { method: 'PUT', body: payload }); + state.catalog.preference = saved; + applyPreference(saved, { persist: true }); + status('Deine Darstellung wurde sicher gespeichert.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function saveProfile() { + const valid = await validateEditor(true); if (!valid) return; + const definition = currentEditorDefinition(); + try { + const profile = state.editingId + ? await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'PUT', body: definition }) + : await apiRequest('/api/themes', { method: 'POST', body: definition }); + await reloadCatalog(); + renderSystemOptions(); + state.editingId = profile.id; + state.selectedThemeId = profile.id; + renderChoices(); renderProfiles(); openEditor(profile); + status('Theme gespeichert.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function deleteProfile() { + if (!state.editingId || !confirm('Dieses Theme wirklich löschen? Benutzer mit diesem Theme fallen auf den Systemstandard zurück.')) return; + try { + await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'DELETE' }); + await reloadCatalog(); renderSystemOptions(); state.selectedThemeId = state.catalog.preference.theme_id; renderChoices(); renderProfiles(); openEditor(); + status('Theme gelöscht.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function saveSystemDefault() { + const payload = { theme_id: $('#theme-system-default').value, density: $('#theme-system-density').value, reduced_motion: $('#theme-system-motion').value }; + try { state.catalog.system_default = await apiRequest('/api/themes/system-default', { method: 'PUT', body: payload }); status('Systemstandard gespeichert.', 'success'); renderChoices(); } + catch (error) { status(error.message, 'danger'); } +} + +async function exportProfile(id) { + try { + const payload = await apiRequest(`/api/themes/${encodeURIComponent(id)}/export`); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${id}.vendoo-theme.json`; link.click(); URL.revokeObjectURL(link.href); + } catch (error) { status(error.message, 'danger'); } +} + +function importProfile(file) { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const payload = JSON.parse(String(reader.result)); + const profile = payload.theme || payload; + state.editingId = null; state.draftId = `custom-${Date.now().toString(36)}`; state.editorValues = { ...(profile.values || {}) }; + $('#theme-editor-name').value = `${profile.name || 'Importiertes Theme'} Kopie`; + $('#theme-editor-base').value = profile.base_theme || 'light'; renderTokenFields(); status('Import geladen. Prüfen und anschließend speichern.'); + } catch { status('Die Theme-Datei ist ungültig.', 'danger'); } + }; + reader.readAsText(file); +} + +async function reloadCatalog() { + state.catalog = await apiRequest('/api/themes'); +} + +async function init() { + try { + [state.catalog, state.me] = await Promise.all([apiRequest('/api/themes'), apiRequest('/api/me')]); + state.selectedThemeId = state.catalog.preference.theme_id; + $('#theme-density').value = state.catalog.preference.density; + $('#theme-reduced-motion').value = state.catalog.preference.reduced_motion; + $('#theme-system-default').value = state.catalog.system_default.theme_id; + $('#theme-system-density').value = state.catalog.system_default.density; + $('#theme-system-motion').value = state.catalog.system_default.reduced_motion; + renderSystemOptions(); + const canManage = state.me.permissions?.includes('settings.manage'); + root.querySelectorAll('.theme-admin-only').forEach(node => node.classList.toggle('hidden', !canManage)); + renderChoices(); renderProfiles(); openEditor(); applyPreference(state.catalog.effective_preference, { persist: true }); + status('Theme Manager bereit.'); + } catch (error) { status(`Theme Manager konnte nicht geladen werden: ${error.message}`, 'danger'); } +} + +$('#theme-preference-save').addEventListener('click', savePreference); +$('#theme-density').addEventListener('change', () => selectTheme(state.selectedThemeId)); +$('#theme-reduced-motion').addEventListener('change', () => selectTheme(state.selectedThemeId)); +$('#theme-new-btn').addEventListener('click', () => openEditor()); +$('#theme-editor-base').addEventListener('change', () => { state.editorValues = {}; renderTokenFields(); }); +$('#theme-editor-name').addEventListener('input', previewEditor); +$('#theme-validate-btn').addEventListener('click', () => validateEditor(true)); +$('#theme-save-btn').addEventListener('click', saveProfile); +$('#theme-delete-btn').addEventListener('click', deleteProfile); +$('#theme-preview-reset').addEventListener('click', () => applyPreference(state.catalog.preference, { persist: false })); +$('#theme-system-save').addEventListener('click', saveSystemDefault); +$('#theme-import-input').addEventListener('change', event => importProfile(event.target.files?.[0])); + +init(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/style.css b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/style.css new file mode 100644 index 0000000..536bb6f --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/style.css @@ -0,0 +1,7377 @@ +@import url('./design-system/tokens.css?v=1.36.0'); + +:root { + color-scheme: light; + /* Kompatibilitätsbrücke: bestehende Oberfläche bleibt unverändert, neue Module verwenden --vd-* direkt. */ + --bg: var(--vd-canvas); + --bg-card: var(--vd-surface-raised); + --bg-input: var(--vd-surface-raised); + --bg-hover: var(--vd-surface-muted); + --bg-page: var(--vd-surface-quiet); + --border: var(--vd-line); + --border-focus: var(--vd-brand); + --text: var(--vd-ink); + --text-muted: var(--vd-ink-muted); + --text-secondary: var(--vd-ink-secondary); + --accent: var(--vd-brand); + --accent-hover: var(--vd-brand-strong); + --accent-light: var(--vd-brand-soft); + --orange: var(--vd-warning); + --orange-hover: var(--vd-warning); + --orange-light: var(--vd-warning-soft); + --success: var(--vd-success); + --danger: var(--vd-danger); + --warning: var(--vd-warning); + --radius: var(--vd-radius-md); + --radius-sm: var(--vd-radius-sm); + --shadow-sm: none; + --shadow: var(--vd-shadow-soft); +} + +[data-theme="dark"] { color-scheme: dark; } +[data-theme="contrast"] { color-scheme: dark; } + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; +} + +/* --- Sidebar --- */ +.sidebar { + width: 230px; + background: var(--bg-card); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 120; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.sidebar-header { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 15px; + flex-shrink: 0; +} + +.logo-text { + font-weight: 700; + font-size: 17px; + color: var(--accent); + letter-spacing: -0.3px; +} + +.sidebar-nav { + flex: 1; + padding: 4px 0; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} +.sidebar-nav::-webkit-scrollbar { + display: none; +} + +.nav-group { + padding: 10px 10px 2px; +} + +.nav-group-label { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.7px; + padding: 0 8px 6px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: none; + background: none; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + text-align: left; +} + +.nav-item svg { flex-shrink: 0; opacity: 0.7; } +.nav-item span { flex: 1; } +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item:hover svg { opacity: 1; } +.nav-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 600; +} +.nav-item.active svg { opacity: 1; color: var(--accent); } + +.nav-badge { + font-size: 10px; + font-weight: 700; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9px; + padding: 0 5px; + background: var(--danger); + color: white; + line-height: 1; +} + +.sidebar-footer { + margin-top: auto; + padding: 10px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: var(--radius-sm); +} + +.sidebar-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} + +.sidebar-user-info { flex: 1; min-width: 0; } +.sidebar-user-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-user-role { + font-size: 11px; + color: var(--text-muted); + text-transform: capitalize; +} + +.sidebar-logout { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.sidebar-logout:hover { color: var(--danger); background: rgba(192,57,43,0.08); } + +.sidebar-footer .theme-toggle { + width: 100%; + justify-content: center; + border-radius: var(--radius-sm); + height: 32px; +} + +/* --- Main Wrapper --- */ +.main-wrapper { + flex: 1; + margin-left: 230px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 28px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + min-height: 56px; +} + +.topbar-title { + font-size: 18px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.topbar-actions { display: flex; gap: 8px; align-items: center; } + +.topbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-card); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} +.topbar-btn:hover { border-color: var(--accent); color: var(--accent); } +.topbar-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.topbar-btn.primary:hover { background: var(--accent-hover); } + +main { max-width: 960px; margin: 0 auto; padding: 24px 28px; width: 100%; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +/* Generator Grid */ +.generator-grid { display: grid; gap: 20px; align-items: start; } +@media (min-width: 600px) { .generator-grid { grid-template-columns: 1fr 240px; } } + +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-card); +} +.dropzone:hover, .dropzone.dragover { + border-color: var(--accent); + background: var(--accent-light); +} +.dropzone p { color: var(--text-muted); font-size: 14px; line-height: 1.6; } + +.photo-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 8px; margin-top: 12px; } +.photo-preview .thumb { position: relative; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden; box-shadow: var(--shadow-sm); } +.photo-preview .thumb img { width: 100%; height: 100%; object-fit: cover; } +.photo-preview .thumb .remove-photo { + position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; + border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; opacity: 0; transition: opacity 0.15s; +} +.photo-preview .thumb .edit-photo { + position: absolute; top: 4px; left: 4px; width: 24px; height: 24px; + border-radius: 50%; background: var(--accent); color: white; border: 2px solid white; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; transition: transform 0.15s, background 0.15s; + z-index: 2; box-shadow: 0 1px 4px rgba(0,0,0,0.3); +} +.photo-preview .thumb .edit-photo:hover { background: var(--accent-hover); transform: scale(1.15); } +.photo-preview .thumb:hover .remove-photo { opacity: 1; } + +.image-tools { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + +.config-section { display: flex; flex-direction: column; gap: 14px; } +.config-group label { + display: block; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); margin-bottom: 6px; +} +.button-group { display: flex; gap: 5px; flex-wrap: wrap; } + +.toggle-btn { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-secondary); padding: 6px 12px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; +} +.toggle-btn:hover { border-color: var(--accent); color: var(--accent); } +.toggle-btn.active { background: var(--accent); border-color: var(--accent); color: white; } + +.model-select, select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 7px 10px; border-radius: var(--radius-sm); + font-size: 13px; cursor: pointer; +} +.model-select:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.primary-btn { + background: var(--accent); color: white; border: none; + padding: 10px 20px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; + box-shadow: var(--shadow-sm); +} +.primary-btn:hover { background: var(--accent-hover); } +.primary-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.secondary-btn { + background: var(--bg-card); color: var(--text); border: 1.5px solid var(--border); + padding: 8px 16px; border-radius: var(--radius-sm); font-size: 13px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.secondary-btn:hover { background: var(--bg-hover); border-color: var(--text-muted); } + +.small-btn { + background: var(--bg-card); color: var(--text-secondary); border: 1.5px solid var(--border); + padding: 5px 10px; border-radius: var(--radius-sm); font-size: 12px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.small-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* Meta Section */ +.meta-section { + margin-top: 20px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.meta-section h2 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; } +.meta-grid-edit { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } +.meta-edit-item { display: flex; flex-direction: column; gap: 3px; } +.meta-item { display: flex; flex-direction: column; gap: 2px; } +.meta-label { font-size: 10px; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.6px; font-weight: 600; } +.meta-value { font-size: 14px; font-weight: 500; } +.meta-input { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 8px; border-radius: var(--radius-sm); font-size: 13px; width: 100%; +} +.meta-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.fee-info { + margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); + font-size: 13px; color: var(--text-muted); +} +.fee-info .fee-net { font-size: 16px; font-weight: 600; color: var(--success); } + +/* Variant tabs */ +.variant-tabs { display: flex; gap: 6px; margin: 16px 0 0; } +.variant-tab { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-muted); padding: 6px 14px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; +} +.variant-tab.active { background: var(--orange); border-color: var(--orange); color: white; } + +/* Results */ +.result-section { + margin-top: 16px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.result-section h2 { font-size: 15px; margin-bottom: 14px; } +.result-field { margin-bottom: 14px; } +.field-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; } +.field-header label { font-size: 12px; font-weight: 600; color: var(--text-muted); } + +.copy-btn { + background: none; border: 1.5px solid var(--border); color: var(--text-muted); + padding: 3px 8px; border-radius: 6px; font-size: 11px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.copy-btn:hover { border-color: var(--orange); color: var(--orange); } + +.result-input, .result-textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.result-input:focus, .result-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.result-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* Loading */ +.loading { text-align: center; padding: 48px; } +.spinner { + width: 36px; height: 36px; border: 3px solid var(--border); + border-top-color: var(--orange); border-radius: 50%; + margin: 0 auto 16px; animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.loading p { color: var(--text-muted); font-size: 14px; } + +/* History */ +.history-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.history-toolbar input, .history-toolbar select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.history-toolbar input { flex: 1; min-width: 150px; } +.history-toolbar input:focus, .history-toolbar select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +/* History batch bar */ +.history-batch-bar { + display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; + padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); +} +.history-select-all-wrap { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; font-weight: 500; } +.history-select-all-wrap input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.history-batch-bar span { font-size: 13px; color: var(--text-muted); } +.danger-btn { color: var(--danger) !important; border-color: var(--danger) !important; } +.danger-btn:hover { background: rgba(192,57,43,0.08) !important; } +.danger-btn:disabled { opacity: 0.4; } +.history-toolbar input[type="date"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12px; +} + +.history-list { display: flex; flex-direction: column; gap: 6px; } +.history-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; cursor: pointer; transition: all 0.15s; + box-shadow: var(--shadow-sm); +} +.history-item:hover { border-color: var(--accent); box-shadow: var(--shadow); } +.history-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.history-item .item-info { flex: 1; min-width: 0; } +.history-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.history-item .item-actions { display: flex; gap: 4px; align-items: center; } + +.status-badge { + font-size: 10px; padding: 3px 8px; border-radius: 4px; text-transform: uppercase; font-weight: 600; +} +.status-active { background: rgba(44,110,73,0.1); color: var(--success); } +.status-sold { background: rgba(192,57,43,0.1); color: var(--danger); } +.status-reserved { background: var(--orange-light); color: var(--warning); } + +.status-select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 3px 6px; border-radius: 4px; font-size: 11px; cursor: pointer; +} + +.history-item .delete-btn { + background: none; border: 1px solid transparent; color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.history-item .delete-btn:hover { color: var(--danger); border-color: var(--danger); } + +.history-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } + +.history-detail { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); } +.history-detail h3 { margin: 14px 0 6px; font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; } +.history-detail p, .history-detail .detail-value { font-size: 14px; line-height: 1.6; white-space: pre-wrap; } +.history-detail .detail-photos { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.history-detail .detail-photos img { width: 100px; height: 100px; object-fit: cover; border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); } + +/* Detail edit fields */ +.detail-edit-group { margin-bottom: 14px; } +.detail-edit-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 4px; } +.detail-edit-group input, .detail-edit-group textarea, .detail-edit-group select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.detail-edit-group input:focus, .detail-edit-group textarea:focus, .detail-edit-group select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); +} +.detail-edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 480px) { .detail-edit-row { grid-template-columns: 1fr; } } +.detail-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Templates */ +.templates-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.templates-header h2 { font-size: 16px; } +.template-list { display: flex; flex-direction: column; gap: 6px; } +.template-item { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 16px; + box-shadow: var(--shadow-sm); +} +.template-item .tpl-info { flex: 1; } +.template-item .tpl-name { font-size: 14px; font-weight: 500; } +.template-item .tpl-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.template-item .tpl-actions { display: flex; gap: 6px; } +.template-form { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-top: 16px; box-shadow: var(--shadow-sm); } +.template-form h3 { font-size: 15px; margin-bottom: 16px; } + +/* Fee calculator */ +.fee-results { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; } +.fee-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 14px 18px; + display: flex; justify-content: space-between; align-items: center; + box-shadow: var(--shadow-sm); +} +.fee-card .fee-platform { font-weight: 600; font-size: 14px; } +.fee-card .fee-breakdown { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.fee-card .fee-amounts { text-align: right; } +.fee-card .fee-net-amount { font-size: 18px; font-weight: 700; color: var(--success); } +.fee-card .fee-deducted { font-size: 12px; color: var(--text-muted); } + +/* Inventory */ +.inventory-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.inventory-header h2 { margin: 0; } +.inventory-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; } +.inv-stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; text-align: center; } +.inv-stat-value { font-size: 24px; font-weight: 700; color: var(--accent); } +.inv-stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.inventory-locations { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } +.inv-location-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } +.inv-location-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.06); } +.inv-loc-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.inv-loc-icon { color: var(--accent); flex-shrink: 0; } +.inv-loc-name { font-weight: 600; font-size: 15px; } +.inv-loc-stats { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text-muted); } +.inv-loc-count { font-weight: 500; color: var(--text); } +.inv-loc-sold { color: var(--orange); } +.inv-loc-reserved { color: var(--accent); } +.inv-loc-value { margin-top: 8px; font-size: 16px; font-weight: 600; color: var(--accent); } +.inv-unassigned { border-style: dashed; opacity: 0.8; } +.inv-unassigned .inv-loc-icon { color: var(--text-muted); } +.inventory-detail-actions { display: flex; gap: 8px; margin: 12px 0; } +.inv-item-check { margin-right: 10px; } + +/* Settings */ +.settings-form { max-width: 500px; } +.setting-group { margin-bottom: 18px; } +.setting-group > label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; } +.setting-group small { display: block; margin-top: 3px; color: var(--text-muted); font-size: 12px; } +.setting-group input, .setting-group select, .setting-group textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.setting-group input:focus, .setting-group select:focus, .setting-group textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.key-input-wrap { display: flex; gap: 6px; } +.key-input-wrap input { flex: 1; } +.toggle-visibility { + background: var(--bg-card); border: 1.5px solid var(--border); color: var(--text-muted); + padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; white-space: nowrap; + font-weight: 500; +} +.toggle-visibility:hover { border-color: var(--accent); color: var(--accent); } + +/* Toast */ +.toast { + position: fixed; bottom: 24px; right: 24px; + background: var(--bg-card); border: 1.5px solid var(--success); + color: var(--text); padding: 10px 18px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 500; transform: translateY(100px); opacity: 0; + transition: all 0.3s; z-index: 1000; box-shadow: var(--shadow); +} +.toast.show { transform: translateY(0); opacity: 1; } + +/* Publish tab */ +.publish-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.publish-header h2 { font-size: 16px; } +.publish-actions-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.publish-actions-bar span { font-size: 13px; color: var(--text-muted); font-weight: 500; } +.publish-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.publish-filters input, .publish-filters select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.publish-filters input { flex: 1; min-width: 150px; } +.publish-filters input:focus, .publish-filters select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.publish-list { display: flex; flex-direction: column; gap: 6px; } +.publish-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); +} +.publish-item:hover { border-color: var(--accent); } +.publish-item input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } +.publish-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.publish-item .item-info { flex: 1; min-width: 0; } +.publish-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.publish-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.publish-item .item-publish-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.publish-badges { display: flex; gap: 4px; margin-right: 8px; } +.publish-badge { + font-size: 9px; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; font-weight: 600; +} +.publish-badge.copied { background: var(--orange-light); color: var(--orange); } +.publish-badge.published { background: rgba(44,110,73,0.1); color: var(--success); } + +/* Modal */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 500; + display: flex; align-items: center; justify-content: center; padding: 24px; +} +.modal { + background: var(--bg-card); border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.15); + max-width: 560px; width: 100%; max-height: 85vh; overflow-y: auto; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); + position: sticky; top: 0; background: var(--bg-card); z-index: 1; +} +.modal-header h3 { font-size: 15px; } +.modal-close { + background: none; border: none; font-size: 22px; color: var(--text-muted); + cursor: pointer; padding: 0 4px; line-height: 1; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 20px; } + +.smart-copy-field { margin-bottom: 14px; } +.smart-copy-field .field-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } +.smart-copy-field label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); } +.smart-copy-field input, .smart-copy-field textarea { + width: 100%; background: var(--bg-page); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 13px; font-family: inherit; resize: vertical; +} +.smart-copy-field textarea { min-height: 80px; } + +.smart-copy-steps { + background: var(--accent-light); border-radius: var(--radius-sm); + padding: 14px 16px; margin-bottom: 16px; +} +.smart-copy-steps h4 { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--accent); } +.smart-copy-steps ol { padding-left: 18px; font-size: 13px; line-height: 1.8; color: var(--text-secondary); } + +.smart-copy-photos { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.smart-copy-photos img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; box-shadow: var(--shadow-sm); } + +.smart-copy-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Etsy connect */ +.etsy-connect-wrap { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.etsy-status { font-size: 12px; font-weight: 600; } +.etsy-status.connected { color: var(--success); } +.etsy-status.disconnected { color: var(--text-muted); } + +/* HTML Preview */ +.html-preview { + background: #fff; border: 1.5px solid var(--border); border-radius: var(--radius-sm); + padding: 20px; min-height: 80px; line-height: 1.6; font-size: 14px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.04); +} +.html-source { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; line-height: 1.5; white-space: pre-wrap; tab-size: 2; + background: #2c2c2a; color: #e8e8e0; border-radius: var(--radius-sm); + padding: 14px; +} + +/* Quill editor overrides */ +.ql-toolbar.ql-snow { + border: 1.5px solid var(--border) !important; + border-radius: var(--radius-sm) var(--radius-sm) 0 0 !important; + background: var(--bg-page) !important; + font-family: inherit !important; +} +.ql-container.ql-snow { + border: 1.5px solid var(--border) !important; + border-top: none !important; + border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important; + font-family: inherit !important; + font-size: 14px !important; + min-height: 120px; + background: var(--bg-input); +} +.ql-editor { min-height: 100px; line-height: 1.7; color: var(--text); } +.ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; } +.ql-snow .ql-stroke { stroke: var(--text-secondary) !important; } +.ql-snow .ql-fill { fill: var(--text-secondary) !important; } +.ql-snow .ql-picker-label { color: var(--text-secondary) !important; } +.ql-snow .ql-active .ql-stroke { stroke: var(--accent) !important; } +.ql-snow .ql-active .ql-fill { fill: var(--accent) !important; } +.ql-snow .ql-active { color: var(--accent) !important; } +.ql-snow .ql-picker-options { background: var(--bg-card) !important; border-color: var(--border) !important; border-radius: var(--radius-sm) !important; } +.ql-editor:focus { box-shadow: inset 0 0 0 1px var(--accent); } + +.hidden { display: none !important; } +.empty-state { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; } + +/* Category Picker */ +.cat-picker-wrap { position: relative; } +.cat-picker-wrap .cat-search { width: 100%; } +.cat-dropdown { + display: none; position: absolute; top: 100%; left: 0; right: 0; + background: var(--bg-card); border: 1px solid var(--border); + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + max-height: 260px; overflow-y: auto; z-index: 20; + scrollbar-width: thin; +} +.cat-empty { padding: 14px; text-align: center; color: var(--text-muted); font-size: 13px; } +.cat-result-item { + padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--bg-hover); +} +.cat-result-item:hover { background: var(--accent-light); } +.cat-result-name { font-weight: 600; font-size: 13px; color: var(--text); } +.cat-result-path { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.cat-group-hd { + padding: 7px 10px; font-size: 13px; font-weight: 700; color: var(--accent); + cursor: pointer; background: var(--bg-hover); border-bottom: 1px solid var(--border); +} +.cat-group-hd:hover { background: var(--bg-page); } +.cat-group-hd::before { content: '▸ '; font-size: 10px; color: var(--text-muted); } +.cat-group-hd.expanded::before { content: '▾ '; } +.cat-sub-hd { + padding: 5px 10px 5px 18px; font-size: 12px; font-weight: 600; + color: var(--text-secondary); background: var(--bg); +} +.cat-leaf { + padding: 5px 10px 5px 28px; font-size: 12px; cursor: pointer; color: var(--text); + border-bottom: 1px solid rgba(0,0,0,0.03); +} +.cat-leaf:hover { background: var(--accent-light); color: var(--accent); } +.cat-selected-display { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; background: var(--accent-light); + border: 1px solid var(--accent); font-size: 13px; + color: var(--accent); font-weight: 500; +} +.cat-sel-clear { + margin-left: auto; cursor: pointer; font-size: 16px; line-height: 1; + color: var(--text-muted); background: none; border: none; padding: 0 2px; +} +.cat-sel-clear:hover { color: var(--danger); } + +/* SKU Badge */ +.sku-badge { + display: inline-block; font-size: 10px; font-weight: 700; + background: var(--accent-light); color: var(--accent); + padding: 1px 5px; margin-right: 6px; letter-spacing: 0.3px; +} + +/* Publish badges in history */ +.item-pub-badges { display: flex; gap: 4px; margin-top: 3px; flex-wrap: wrap; } +.publish-badge { + font-size: 9px; font-weight: 600; padding: 1px 6px; + text-transform: uppercase; letter-spacing: 0.3px; +} +.pub-published { background: #e8f5e9; color: var(--accent); } +.pub-copied { background: var(--orange-light); color: var(--orange); } +.pub-pending { background: #f0f0f0; color: #999; } +.pub-failed { background: #fce4e4; color: #c62828; } + +/* Detail header with QR */ +.detail-header-row { display: flex; gap: 16px; align-items: flex-start; } +.detail-header-row .detail-photos { flex: 1; } +.detail-qr-wrap { + flex-shrink: 0; display: flex; flex-direction: column; + align-items: center; gap: 4px; padding: 8px; + background: var(--bg); border: 1px solid var(--border); +} +.detail-sku-big { font-size: 14px; font-weight: 800; color: var(--accent); letter-spacing: 0.5px; } + +/* Dark Mode */ +[data-theme="dark"] { + --bg: #1a1a1e; + --bg-card: #242428; + --bg-input: #2c2c30; + --bg-hover: #32323a; + --bg-page: #18181c; + --border: #3a3a42; + --border-focus: #3d9e6a; + --text: #e4e4e0; + --text-muted: #8a8a90; + --text-secondary: #b0b0b4; + --accent: #3d9e6a; + --accent-hover: #4db87a; + --accent-light: rgba(61,158,106,0.12); + --orange: #e09040; + --orange-hover: #d08030; + --orange-light: rgba(224,144,64,0.12); + --success: #3d9e6a; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.2); + --shadow: 0 2px 8px rgba(0,0,0,0.3); +} +[data-theme="dark"] .sidebar { background: #1e1e22; border-color: var(--border); } +[data-theme="dark"] .html-preview { background: var(--bg-input); } +[data-theme="dark"] .ql-editor { color: var(--text); } +[data-theme="dark"] .ql-container.ql-snow { background: var(--bg-input) !important; } +[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--bg-hover) !important; } + +.theme-toggle { + background: none; border: 1px solid var(--border); color: var(--text-muted); + height: 32px; border-radius: var(--radius-sm); cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 14px; transition: all 0.15s; flex-shrink: 0; + padding: 0 8px; gap: 6px; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } + +/* Dashboard */ +.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.stat-card .stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); font-weight: 600; } +.stat-card .stat-value { font-size: 28px; font-weight: 800; color: var(--text); margin-top: 4px; } +.stat-card .stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.orange .stat-value { color: var(--orange); } + +.chart-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); + margin-bottom: 16px; +} +.chart-card h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.chart-bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.chart-bar-label { font-size: 12px; min-width: 100px; color: var(--text-secondary); } +.chart-bar-track { flex: 1; height: 22px; background: var(--bg-hover); border-radius: 4px; overflow: hidden; } +.chart-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; display: flex; align-items: center; padding-left: 8px; font-size: 11px; font-weight: 600; color: #fff; } +.chart-bar-fill.green { background: var(--accent); } +.chart-bar-fill.orange { background: var(--orange); } +.chart-bar-fill.blue { background: #4a90d9; } + +/* Advanced Image Editor */ +.photo-editor-overlay { + position: fixed; inset: 0; z-index: 600; display: flex; align-items: center; justify-content: center; + padding: 18px; background: rgba(8, 10, 12, .78); backdrop-filter: blur(8px); +} +.photo-editor { + width: min(1420px, 98vw); max-height: 94vh; overflow: hidden; display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--vd-surface, var(--bg-card)); + border: 1px solid var(--vd-line, var(--border)); border-radius: 16px; box-shadow: 0 28px 90px rgba(0,0,0,.42); +} +.photo-editor-header { + display: flex; justify-content: space-between; align-items: center; gap: 18px; + padding: 15px 18px; border-bottom: 1px solid var(--vd-line, var(--border)); +} +.photo-editor-header > div { min-width: 0; } +.photo-editor-header h3 { margin: 2px 0 0; color: var(--vd-ink, var(--text)); font-size: 18px; } +.photo-editor-header small { display: block; margin-top: 4px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 11px; } +.photo-editor-kicker { color: var(--vd-brand, var(--accent)); font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; } +.photo-editor-toolbar { + display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding: 9px 18px; + border-bottom: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface-muted, var(--bg-page)); +} +.photo-editor-toolbar .small-btn.active, .pe-button-grid .small-btn.active { background: var(--vd-brand, var(--accent)); color: #fff; border-color: transparent; } +.pe-history-status { margin-left: auto; color: var(--vd-ink-muted, var(--text-muted)); font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.photo-editor-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 360px; overflow: hidden; } +.photo-editor-stage { + min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: stretch; + gap: 10px; padding: 14px; overflow: auto; background: + linear-gradient(45deg, rgba(120,120,120,.06) 25%, transparent 25%), + linear-gradient(-45deg, rgba(120,120,120,.06) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(120,120,120,.06) 75%), + linear-gradient(-45deg, transparent 75%, rgba(120,120,120,.06) 75%); + background-size: 24px 24px; background-position: 0 0, 0 12px, 12px -12px, -12px 0; +} +.photo-editor-stage:has(.pe-before-pane:not(.hidden)) { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.pe-preview-pane { min-width: 0; min-height: 420px; position: relative; display: flex; align-items: center; justify-content: center; padding: 28px 12px 12px; border: 1px solid var(--vd-line, var(--border)); border-radius: 12px; background: rgba(15,18,20,.78); } +.pe-preview-pane > span { position: absolute; top: 8px; left: 10px; z-index: 2; padding: 4px 7px; border-radius: 999px; color: #fff; background: rgba(0,0,0,.58); font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .08em; } +.pe-preview-pane canvas { display: block; max-width: 100%; max-height: calc(94vh - 250px); object-fit: contain; border-radius: 5px; box-shadow: 0 12px 36px rgba(0,0,0,.34); } +.photo-editor-canvas-wrap { min-width: 0; min-height: 0; width: 100%; height: 100%; position: relative; display: flex; align-items: center; justify-content: center; } +.photo-editor-canvas-wrap .crop-overlay { position: absolute; z-index: 4; cursor: crosshair; touch-action: none; border-radius: 5px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); } +.pe-stage-footer { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--vd-ink-muted, var(--text-muted)); font-size: 10px; } +.photo-editor-sidebar { min-height: 0; overflow-y: auto; padding: 12px; border-left: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface-muted, var(--bg-page)); } +.pe-control-group { margin-bottom: 9px; border: 1px solid var(--vd-line, var(--border)); border-radius: 10px; background: var(--vd-surface, var(--bg-card)); overflow: hidden; } +.pe-control-group > summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; padding: 10px 11px; color: var(--vd-ink, var(--text)); font-size: 11px; font-weight: 800; } +.pe-control-group > summary::-webkit-details-marker { display: none; } +.pe-control-group > summary::after { content: '⌄'; color: var(--vd-ink-muted, var(--text-muted)); } +.pe-control-group:not([open]) > summary::after { content: '›'; } +.pe-control-body { display: grid; gap: 9px; padding: 10px 11px 12px; border-top: 1px solid var(--vd-line, var(--border)); } +.pe-control-body .editor-row { display: grid; grid-template-columns: 88px minmax(0,1fr) 44px; gap: 8px; align-items: center; } +.pe-control-body .editor-row label, .pe-export-grid > label, .pe-crop-controls > label { color: var(--vd-ink-muted, var(--text-muted)); font-size: 10px; font-weight: 700; } +.pe-control-body input[type="range"] { width: 100%; accent-color: var(--vd-brand, var(--accent)); } +.pe-control-body .range-val { min-width: 40px; color: var(--vd-ink-secondary, var(--text-secondary)); font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; } +.pe-button-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; } +.pe-button-grid.three { grid-template-columns: repeat(3, minmax(0,1fr)); } +.pe-button-grid .small-btn { justify-content: center; } +.pe-crop-controls { display: grid; grid-template-columns: 88px minmax(0,1fr); gap: 8px; align-items: center; } +.pe-crop-controls .pe-button-grid { grid-column: 1 / -1; } +.pe-control-body select, .pe-control-body input[type="number"] { width: 100%; min-height: 34px; padding: 7px 9px; border: 1px solid var(--vd-line, var(--border)); border-radius: 7px; color: var(--vd-ink, var(--text)); background: var(--vd-surface, var(--bg-input)); } +.pe-export-grid { grid-template-columns: 88px minmax(0,1fr); align-items: center; } +.pe-inline-control { display: grid; grid-template-columns: minmax(0,1fr) 40px; gap: 8px; align-items: center; } +.pe-custom-size { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto 1fr; gap: 7px; align-items: center; } +.pe-version-list { display: grid; gap: 6px; max-height: 210px; overflow-y: auto; } +.pe-version-item { width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr); gap: 8px; align-items: center; padding: 8px; border: 1px solid var(--vd-line, var(--border)); border-radius: 8px; color: var(--vd-ink, var(--text)); background: var(--vd-surface-muted, var(--bg-page)); text-align: left; cursor: pointer; } +.pe-version-item:hover, .pe-version-item.active { border-color: var(--vd-brand, var(--accent)); background: color-mix(in srgb, var(--vd-brand, var(--accent)) 9%, var(--vd-surface, var(--bg-card))); } +.pe-version-item > span { display: grid; place-items: center; min-height: 30px; border-radius: 6px; color: var(--vd-brand, var(--accent)); background: color-mix(in srgb, var(--vd-brand, var(--accent)) 12%, transparent); font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.pe-version-item strong { display: block; font-size: 10px; } +.pe-version-item small { display: block; margin-top: 3px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 8px; line-height: 1.35; } +.photo-editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface, var(--bg-card)); } +.photo-editor-actions > div { margin-right: auto; } +.photo-editor-actions strong { display: block; color: var(--vd-ink, var(--text)); font-size: 10px; } +.photo-editor-actions small { display: block; margin-top: 2px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 9px; } +@media (max-width: 980px) { + .photo-editor { max-height: 96vh; } + .photo-editor-workspace { grid-template-columns: 1fr; overflow-y: auto; } + .photo-editor-sidebar { overflow: visible; border-left: 0; border-top: 1px solid var(--vd-line, var(--border)); } + .pe-preview-pane { min-height: 320px; } + .pe-preview-pane canvas { max-height: 52vh; } +} +@media (max-width: 680px) { + .photo-editor-overlay { padding: 0; } + .photo-editor { width: 100vw; max-height: 100vh; min-height: 100vh; border-radius: 0; } + .photo-editor-toolbar { padding-inline: 10px; } + .photo-editor-stage:has(.pe-before-pane:not(.hidden)) { grid-template-columns: 1fr; } + .photo-editor-stage { padding: 9px; } + .pe-preview-pane { min-height: 280px; } + .pe-history-status { width: 100%; margin-left: 0; } + .photo-editor-actions { flex-wrap: wrap; } + .photo-editor-actions > div { width: 100%; margin-right: 0; } + .photo-editor-actions button { flex: 1; } +} + + +/* Drag handle for sortable items */ +.drag-handle { + cursor: grab; color: var(--text-muted); font-size: 16px; padding: 0 4px; + user-select: none; touch-action: none; +} +.drag-handle:active { cursor: grabbing; } +.photo-preview .thumb.dragging { opacity: 0.4; } +.photo-preview .thumb.drag-over { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Scheduling */ +.schedule-wrap { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.schedule-wrap input[type="datetime-local"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 10px; border-radius: var(--radius-sm); font-size: 13px; +} + +/* Backup section */ +.backup-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); } +.backup-section h3 { font-size: 14px; margin-bottom: 12px; } +.backup-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* --- Role Badge (used in admin) --- */ +.role-badge { + display: inline-block; padding: 2px 10px; border-radius: 12px; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 6px; +} +.role-badge.admin { background: #ffeaa7; color: #856404; } +.role-badge.editor { background: #dfe6e9; color: #636e72; } + +/* --- Admin Panel --- */ +.admin-wrap { max-width: 900px; } +.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } +.admin-header h2 { font-size: 18px; } +.admin-tabs { display: flex; gap: 2px; margin-bottom: 16px; } +.admin-tab { + padding: 8px 16px; background: var(--bg); border: 1px solid var(--border); + border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; font-weight: 500; + color: var(--text-muted); transition: all 0.15s; border-bottom: none; +} +.admin-tab.active { background: var(--bg-card); color: var(--text); font-weight: 600; border-bottom: 2px solid var(--accent); } +.admin-panel { display: none; } +.admin-panel.active { display: block; } + +/* User list */ +.users-list { display: flex; flex-direction: column; gap: 8px; } +.user-card { + display: flex; align-items: center; gap: 14px; padding: 14px 18px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; + transition: box-shadow 0.15s; +} +.user-card:hover { box-shadow: var(--shadow); } +.user-card-avatar { + width: 40px; height: 40px; border-radius: 50%; + font-size: 14px; font-weight: 700; color: white; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.user-card-info { flex: 1; min-width: 0; } +.user-card-info .name { font-weight: 600; font-size: 14px; color: var(--text); } +.user-card-info .email { font-size: 12px; color: var(--text-muted); } +.user-card-info .meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; } +.user-card-actions { display: flex; gap: 6px; align-items: center; } +.user-card .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.user-card .status-dot.online { background: #27ae60; } +.user-card .status-dot.offline { background: #bbb; } +.user-card .status-dot.inactive { background: var(--danger); } + +/* Sessions list */ +.sessions-list .session-row { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; + margin-bottom: 6px; font-size: 13px; +} +.session-row .session-info { flex: 1; } +.session-row .session-info .session-ua { font-size: 11px; color: var(--text-muted); } +.session-row .session-info .session-ip { font-size: 11px; color: var(--text-muted); } +.session-row .session-meta { font-size: 11px; color: var(--text-muted); text-align: right; min-width: 100px; } + +/* Audit log */ +.audit-list { max-height: 600px; overflow-y: auto; } +.audit-row { + display: flex; gap: 10px; padding: 8px 12px; font-size: 12px; + border-bottom: 1px solid var(--border); align-items: center; +} +.audit-row:hover { background: var(--bg-hover); } +.audit-row .audit-time { min-width: 130px; color: var(--text-muted); font-size: 11px; } +.audit-row .audit-user { min-width: 120px; font-weight: 500; } +.audit-row .audit-action { flex: 1; } +.audit-action-badge { + display: inline-block; padding: 1px 8px; border-radius: 4px; + font-size: 10px; font-weight: 600; text-transform: uppercase; +} +.audit-action-badge.login { background: #e3f2fd; color: #1565c0; } +.audit-action-badge.user { background: #fce4ec; color: #c62828; } +.audit-action-badge.listing { background: #e8f5e9; color: #2e7d32; } +.audit-action-badge.session { background: #fff3e0; color: #e65100; } + +/* Modals (reusable) */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 300; + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.modal-overlay.hidden { display: none; } +.modal { + background: var(--bg-card); border-radius: 12px; width: 100%; + box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); +} +.modal-header h3 { font-size: 15px; } +.modal-body { padding: 20px; } +.modal-footer { + padding: 14px 20px; border-top: 1px solid var(--border); + display: flex; gap: 8px; justify-content: flex-end; +} +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 5px; } +.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; background: var(--bg-input); } +.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(44,110,73,0.1); } + +/* SMTP Config */ +.smtp-config-form { max-width: 500px; } +.smtp-status-bar { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; margin-bottom: 18px; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; +} +.smtp-status-dot { + width: 10px; height: 10px; border-radius: 50%; + flex-shrink: 0; display: inline-block; +} +.smtp-status-dot.configured { background: var(--success); } +.smtp-status-dot.not-configured { background: var(--text-muted); } +.smtp-actions { display: flex; gap: 8px; margin-top: 16px; } + +/* System Info Grid */ +.system-info-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.system-info-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.system-info-card .sys-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); font-weight: 600; +} +.system-info-card .sys-value { + font-size: 22px; font-weight: 700; color: var(--text); margin-top: 4px; +} +.system-info-card .sys-value.accent { color: var(--accent); } + +/* Password Status Badge */ +.pw-status-badge { + display: inline-block; padding: 3px 10px; border-radius: 4px; + font-size: 11px; font-weight: 600; +} +.pw-status-badge.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-status-badge.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Login History in Edit Modal */ +.login-history-list { + max-height: 200px; overflow-y: auto; margin-top: 8px; + border: 1px solid var(--border); border-radius: var(--radius-sm); +} +.login-history-item { + display: flex; gap: 8px; align-items: center; + padding: 6px 10px; font-size: 11px; + border-bottom: 1px solid var(--border); +} +.login-history-item:last-child { border-bottom: none; } +.login-history-item .lh-time { min-width: 110px; color: var(--text-muted); } +.login-history-item .lh-ip { color: var(--text-secondary); min-width: 90px; } +.login-history-item .lh-status { font-weight: 600; } +.login-history-item .lh-status.success { color: var(--success); } +.login-history-item .lh-status.failed { color: var(--danger); } + +/* User card has_password indicator */ +.user-card .pw-indicator { + font-size: 10px; padding: 1px 6px; border-radius: 3px; + font-weight: 600; display: inline-block; margin-left: 6px; +} +.pw-indicator.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-indicator.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Pagination */ +.pagination-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); margin-bottom: 8px; box-shadow: var(--shadow-sm); + flex-wrap: wrap; gap: 8px; +} +#history-pagination-bottom, #publish-pagination-bottom { + margin-top: 8px; margin-bottom: 0; +} +.pagination-sizes { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); } +.page-size-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-size-btn:hover { border-color: var(--accent); color: var(--accent); } +.page-size-btn.active { background: var(--accent); border-color: var(--accent); color: white; } +.pagination-nav { display: flex; align-items: center; gap: 8px; } +.page-nav-btn { + background: var(--bg-card); border: 1px solid var(--border); color: var(--text); + padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-nav-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.page-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.page-info { font-size: 12px; color: var(--text-muted); font-weight: 500; min-width: 100px; text-align: center; } + +/* Trash / Papierkorb */ +.trash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.trash-header h2 { font-size: 16px; } +.trash-actions-bar { display: flex; align-items: center; gap: 12px; } +.trash-info-text { font-size: 12px; color: var(--text-muted); } +.trash-list { display: flex; flex-direction: column; gap: 6px; } +.trash-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); + opacity: 0.7; +} +.trash-item:hover { opacity: 1; border-color: var(--border); } +.trash-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); filter: grayscale(40%); } +.trash-item .item-info { flex: 1; min-width: 0; } +.trash-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trash-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.trash-item .trash-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.trash-item .restore-btn { + background: none; border: 1px solid var(--accent); color: var(--accent); + padding: 3px 10px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .restore-btn:hover { background: var(--accent); color: white; } +.trash-item .perm-delete-btn { + background: none; border: 1px solid var(--danger); color: var(--danger); + padding: 3px 8px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .perm-delete-btn:hover { background: var(--danger); color: white; } + +/* Trash badge on tab */ +.trash-badge { + display: inline-flex; align-items: center; justify-content: center; + background: var(--danger); color: white; + font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; + border-radius: 8px; padding: 0 4px; margin-left: 4px; + line-height: 1; +} + +/* Invite method radios */ +.invite-method-radios { display: flex; flex-direction: column; gap: 8px; } +.radio-label { + display: flex; align-items: center; gap: 8px; cursor: pointer; + font-size: 13px; color: var(--text); +} +.radio-label input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; } +.radio-label small { color: var(--text-muted); } + +/* Duplicate & bulk status button */ +.duplicate-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.duplicate-btn:hover { color: var(--accent); border-color: var(--accent); } + +/* Bulk status modal */ +.bulk-status-options { display: flex; gap: 8px; margin-top: 12px; } + +/* Sidebar mobile toggle */ +.sidebar-toggle { + display: none; + background: none; border: none; color: var(--text); + cursor: pointer; padding: 4px; +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform 0.25s ease; + box-shadow: none; + } + .sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0,0,0,0.15); + } + .main-wrapper { margin-left: 0; } + .sidebar-toggle { display: flex; } + .sidebar-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.3); + z-index: 110; display: none; + } + .sidebar-overlay.show { display: block; } +} + +@media (max-width: 480px) { + main { padding: 14px; } + .dashboard-grid { grid-template-columns: 1fr 1fr; } + .admin-header { flex-direction: column; gap: 10px; align-items: flex-start; } + .user-card { flex-direction: column; text-align: center; } + .system-info-grid { grid-template-columns: 1fr 1fr; } + .admin-tabs { flex-wrap: wrap; } + .pagination-bar { flex-direction: column; } + .topbar-actions { gap: 4px; } + .topbar-btn span { display: none; } +} + +/* ========================================================================== + Vendoo Design System Contract 1.0 + Tokenquelle: public/design-system/tokens/source.json + Generiert: public/design-system/tokens.css + ========================================================================== */ + +html { background: var(--vd-canvas); } +body { + background: var(--vd-canvas); + color: var(--vd-ink); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.008em; +} +button, input, select, textarea { font: inherit; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { + outline: 3px solid var(--vd-focus); + outline-offset: 2px; +} + +/* App shell */ +.sidebar { + width: var(--vd-sidebar-width); + background: var(--vd-surface); + border-right: 1px solid var(--vd-line); + padding: 0; + overflow: hidden; +} +.sidebar-header { + height: var(--vd-topbar-height); + padding: 0 22px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--vd-line); +} +.sidebar-logo { gap: 9px; color: var(--vd-brand); } +.logo-mark { flex: 0 0 auto; } +.logo-text { + color: var(--vd-ink); + font-size: 18px; + font-weight: 750; + letter-spacing: -0.045em; +} +.sidebar-nav { padding: 14px 10px 8px; } +.nav-group { padding: 0; } +.nav-group-primary { display: flex; flex-direction: column; gap: 5px; } +.nav-group-tools { margin-top: 14px; display: flex; flex-direction: column; gap: 3px; } +.nav-divider { height: 1px; background: var(--vd-line); margin: 2px 10px 12px; } +.nav-item { + gap: 11px; + min-height: 42px; + padding: 0 12px; + border-radius: var(--vd-radius-sm); + color: var(--vd-ink-secondary); + font-size: 13px; + font-weight: 540; + position: relative; +} +.nav-item::before { + content: ""; + position: absolute; + left: -10px; + width: 2px; + height: 22px; + border-radius: 2px; + background: transparent; +} +.nav-item svg { opacity: .78; } +.nav-item:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.nav-item.active { + color: var(--vd-brand); + background: var(--vd-brand-soft); + font-weight: 650; +} +.nav-item.active::before { background: var(--vd-brand); } +.nav-item.active svg { color: var(--vd-brand); } +.nav-item-quiet { min-height: 36px; font-size: 12px; } +.nav-badge { margin-left: auto; background: var(--vd-danger); } +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface); + gap: 6px; +} +.sidebar-user { padding: 6px; gap: 8px; } +.sidebar-user-avatar, .topbar-avatar { + background: var(--vd-ink); + color: var(--vd-surface-raised); +} +.sidebar-user-info { display: none; } +.sidebar-logout { margin-left: auto; } +.sidebar-footer .theme-toggle { + height: 34px; + justify-content: flex-start; + padding: 0 9px; + gap: 9px; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 12px; +} +.sidebar-footer .theme-toggle:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } + +.main-wrapper { margin-left: var(--vd-sidebar-width); } +.topbar { + min-height: var(--vd-topbar-height); + height: var(--vd-topbar-height); + padding: 0 22px 0 26px; + background: color-mix(in srgb, var(--vd-surface) 94%, transparent); + border-bottom: 1px solid var(--vd-line); + backdrop-filter: blur(14px); + gap: 22px; +} +.topbar-context { display: flex; align-items: center; gap: 10px; min-width: 185px; } +.topbar-context-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + color: var(--vd-ink-secondary); +} +.topbar-heading { display: flex; align-items: baseline; gap: 9px; } +.topbar-title { font-size: 14px; font-weight: 700; letter-spacing: -0.025em; white-space: nowrap; } +.topbar-kicker { font-size: 11px; color: var(--vd-ink-muted); white-space: nowrap; } +.command-search { + width: min(470px, 42vw); + height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px 0 12px; + cursor: pointer; + text-align: left; +} +.command-search:hover { border-color: var(--vd-line-strong); color: var(--vd-ink-secondary); } +.command-search span { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.command-search kbd { + min-width: 34px; + padding: 2px 5px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-xs); + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: center; +} +.topbar-actions { margin-left: auto; gap: 7px; } +.topbar-btn { + height: 36px; + border-radius: var(--vd-radius-sm); + padding: 0 14px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); +} +.topbar-btn.primary { + background: var(--vd-brand); + border-color: var(--vd-brand); + box-shadow: 0 5px 14px var(--vd-brand-soft); +} +.topbar-btn.primary:hover { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); } +.topbar-icon-btn { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 0; + background: transparent; + color: var(--vd-ink-secondary); + border-radius: 50%; + position: relative; + cursor: pointer; +} +.topbar-icon-btn:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.topbar-dot { + position: absolute; + top: 6px; + right: 7px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vd-brand); + box-shadow: 0 0 0 2px var(--vd-surface); +} +.topbar-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 750; +} + +main { + max-width: none; + margin: 0; + padding: 20px 24px 28px; +} +.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; } +.page-heading h2 { font-size: 22px; line-height: 1.15; letter-spacing: -0.045em; } +.page-heading p { margin-top: 5px; color: var(--vd-ink-secondary); font-size: 12px; } +.eyebrow { + margin: 0 0 7px !important; + color: var(--vd-brand) !important; + font-size: 10px !important; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .12em; +} + +/* Studio Flow */ +.studio-flow-page { min-width: 0; } +.studio-summary { display: flex; align-items: center; gap: 14px; color: var(--vd-ink-muted); font-size: 11px; } +.studio-summary strong { color: var(--vd-ink); font-size: 13px; } +.studio-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 14px; + align-items: start; +} +.studio-workspace { min-width: 0; } +.flow-board-wrap { + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: var(--vd-line-strong) transparent; +} +.flow-board { + min-width: 1010px; + display: grid; + grid-template-columns: repeat(6, minmax(150px, 1fr)); + gap: 10px; +} +.flow-column { min-width: 0; } +.flow-column-header { + min-height: 34px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 4px 7px; + border-bottom: 1px solid var(--vd-line); + position: relative; +} +.flow-column:not(:last-child) .flow-column-header::after { + content: "→"; + position: absolute; + right: -10px; + top: 7px; + color: var(--vd-ink-muted); + font-size: 10px; + z-index: 2; +} +.flow-column-title { font-size: 11px; font-weight: 720; color: var(--vd-ink); } +.flow-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 700; +} +.flow-column-list { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; } +.flow-card { + width: 100%; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; + transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; +} +.flow-card:hover { + border-color: var(--vd-line-strong); + transform: translateY(-1px); + box-shadow: var(--vd-shadow-soft); +} +.flow-card.selected { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.flow-card-media { + aspect-ratio: 1.45; + overflow: hidden; + border-radius: 6px; + background: var(--vd-surface-muted); + margin-bottom: 7px; + position: relative; +} +.flow-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; } +.flow-card-placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--vd-ink-muted); } +.flow-card-title { + min-height: 30px; + font-size: 11px; + line-height: 1.32; + font-weight: 680; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.flow-card-sku { margin-top: 4px; color: var(--vd-ink-muted); font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; } +.flow-card-footer { margin-top: 7px; display: flex; align-items: center; gap: 5px; min-height: 20px; } +.flow-platform { font-size: 9px; font-weight: 780; text-transform: uppercase; letter-spacing: .025em; } +.flow-price { margin-left: auto; font-size: 10px; font-weight: 730; white-space: nowrap; } +.flow-state { + margin-left: auto; + min-height: 19px; + padding: 2px 7px; + border-radius: 10px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; + font-weight: 700; + white-space: nowrap; +} +.flow-state.success { background: var(--vd-success-soft); color: var(--vd-success); } +.flow-state.warning { background: var(--vd-warning-soft); color: var(--vd-warning); } +.flow-state.brand { background: var(--vd-brand-soft); color: var(--vd-brand); } +.flow-progress { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-brand) var(--progress), var(--vd-surface-muted) 0); position: relative; } +.flow-progress::after { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--vd-surface-raised); } +.flow-progress span { position: relative; z-index: 1; font-size: 8px; font-weight: 760; } +.flow-empty { + min-height: 116px; + border: 1px dashed var(--vd-line); + border-radius: var(--vd-radius-sm); + display: grid; + place-items: center; + color: var(--vd-ink-muted); + font-size: 10px; + text-align: center; + padding: 12px; +} + +.studio-bottom-grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 12px; margin-top: 14px; } +.work-panel { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + min-width: 0; +} +.work-panel-header { + min-height: 44px; + padding: 0 13px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--vd-line); +} +.work-panel-header > div { display: flex; align-items: center; gap: 8px; } +.work-panel-header h3 { font-size: 12px; letter-spacing: -0.02em; } +.panel-icon { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 800; } +.panel-icon-warning { color: var(--vd-danger); border: 1px solid var(--vd-danger); } +.panel-count { min-width: 22px; height: 20px; border-radius: 10px; background: var(--vd-surface-muted); display: grid; place-items: center; font-size: 10px; font-weight: 700; color: var(--vd-ink-muted); } +.text-action { border: 0; background: transparent; color: var(--vd-brand); font-size: 10px; cursor: pointer; } +.attention-list, .activity-list { padding: 5px 12px; } +.attention-item { + min-height: 47px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border-bottom: 1px solid var(--vd-line); +} +.attention-item:last-child, .activity-item:last-child { border-bottom: 0; } +.attention-thumb { width: 34px; height: 34px; border-radius: 5px; background: var(--vd-surface-muted); object-fit: cover; } +.attention-copy { min-width: 0; } +.attention-title { font-size: 10px; font-weight: 680; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-meta { margin-top: 2px; font-size: 9px; color: var(--vd-ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-action { height: 26px; padding: 0 9px; border: 1px solid var(--vd-line); border-radius: 6px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 9px; font-weight: 650; cursor: pointer; } +.attention-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.activity-item { min-height: 39px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid var(--vd-line); } +.activity-icon { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 9px; } +.activity-copy { font-size: 10px; color: var(--vd-ink-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-time { font-size: 9px; color: var(--vd-ink-muted); white-space: nowrap; } + +.studio-inspector { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + scrollbar-width: thin; +} +.inspector-empty { min-height: 520px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 28px; color: var(--vd-ink-muted); } +.inspector-empty-mark { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); margin-bottom: 14px; } +.inspector-empty h3 { color: var(--vd-ink); font-size: 14px; } +.inspector-empty p { margin-top: 7px; max-width: 210px; font-size: 11px; line-height: 1.5; } +.inspector-content { padding: 10px; } +.inspector-hero { aspect-ratio: 1.42; border-radius: 7px; overflow: hidden; background: var(--vd-surface-muted); } +.inspector-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } +.inspector-thumbs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin-top: 6px; } +.inspector-thumbs img { width: 100%; aspect-ratio: 1; border-radius: 5px; object-fit: cover; background: var(--vd-surface-muted); } +.inspector-title-row { padding: 14px 2px 10px; } +.inspector-title-row h3 { font-size: 15px; line-height: 1.35; letter-spacing: -0.025em; } +.inspector-sku { margin-top: 5px; color: var(--vd-ink-muted); font: 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.inspector-price-row { display: flex; align-items: center; gap: 8px; padding: 0 2px 12px; } +.inspector-platform { font-size: 10px; font-weight: 800; text-transform: uppercase; } +.inspector-price { margin-left: auto; font-size: 18px; font-weight: 760; letter-spacing: -0.04em; } +.inspector-card { margin-top: 8px; padding: 12px; border: 1px solid var(--vd-line); border-radius: 7px; } +.inspector-card h4 { margin-bottom: 9px; font-size: 10px; color: var(--vd-ink-secondary); } +.confidence-row { display: flex; align-items: center; gap: 10px; } +.confidence-ring { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-success) var(--confidence), var(--vd-surface-muted) 0); position: relative; } +.confidence-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface-raised); } +.confidence-ring span { position: relative; z-index: 1; font-size: 10px; font-weight: 780; } +.confidence-copy strong { display: block; color: var(--vd-success); font-size: 10px; } +.confidence-copy span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.4; } +.attribute-list { display: grid; grid-template-columns: 72px 1fr; gap: 6px 9px; font-size: 9px; } +.attribute-list dt { color: var(--vd-ink-muted); } +.attribute-list dd { color: var(--vd-ink-secondary); overflow-wrap: anywhere; } +.price-range { font-size: 15px; font-weight: 730; letter-spacing: -0.03em; } +.recommended-price { display: flex; justify-content: space-between; margin-top: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.recommended-price strong { color: var(--vd-ink); font-size: 12px; } +.inspector-actions { display: grid; gap: 6px; margin-top: 9px; } +.inspector-action { min-height: 36px; border: 1px solid var(--vd-line); border-radius: 7px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 11px; font-weight: 650; cursor: pointer; } +.inspector-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.inspector-action.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: white; } +.inspector-action.primary:hover { background: var(--vd-brand-strong); color: white; } + +/* Legacy component refinement */ +.dropzone, .meta-section, .result-section, .chart-card, .stat-card, .admin-card, .settings-card { + box-shadow: none; +} +input, select, textarea, .model-select, .result-input, .result-textarea, .meta-input { + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +input:focus, select:focus, textarea:focus, .model-select:focus, .result-input:focus, .result-textarea:focus, .meta-input:focus { + border-color: var(--vd-brand); + box-shadow: 0 0 0 3px var(--vd-focus); +} +.primary-btn { background: var(--vd-brand); box-shadow: none; } +.primary-btn:hover { background: var(--vd-brand-strong); } +.secondary-btn, .small-btn { background: var(--vd-surface-raised); border-color: var(--vd-line); } + +@media (max-width: 1180px) { + .studio-layout { grid-template-columns: minmax(0, 1fr); } + .studio-inspector { position: static; max-height: none; } + .inspector-empty { min-height: 180px; } + .inspector-content { display: grid; grid-template-columns: minmax(240px, .85fr) 1.15fr; gap: 12px; } + .inspector-content > * { min-width: 0; } +} + +@media (max-width: 860px) { + :root { --vd-sidebar-width: 148px; } + .command-search { width: min(320px, 38vw); } + .topbar-kicker { display: none; } + .studio-bottom-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .main-wrapper { margin-left: 0; } + .topbar { padding: 0 12px; gap: 8px; } + .topbar-context { min-width: 0; } + .topbar-context-icon { display: none; } + .command-search { flex: 1; width: auto; } + .command-search span { display: none; } + .command-search kbd { display: none; } + .topbar-icon-btn, .topbar-avatar { display: none; } + main { padding: 16px 14px 24px; } + .studio-heading { align-items: flex-start; flex-direction: column; } + .flow-board { min-width: 940px; } + .inspector-content { display: block; } +} + +@media (max-width: 520px) { + .topbar-btn.primary { width: 36px; padding: 0; justify-content: center; } + .topbar-btn.primary span { display: none; } + .topbar-title { max-width: 112px; overflow: hidden; text-overflow: ellipsis; } + .page-heading h2 { font-size: 20px; } + .studio-summary { flex-wrap: wrap; } +} + +/* ========================================================================== + Listings / Historie 2026 + ========================================================================== */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.listings-page { min-width: 0; } +.listings-heading { align-items: flex-start; } +.listings-heading-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} +.vd-button { + min-height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 11px; + font-weight: 650; + cursor: pointer; + transition: border-color .15s ease, color .15s ease, background .15s ease; + white-space: nowrap; +} +.vd-button:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.vd-button.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.vd-button.primary:hover:not(:disabled) { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); color: #fff; } +.vd-button.ghost { background: transparent; } +.vd-button.compact { min-height: 31px; padding: 0 10px; font-size: 10px; } +.vd-button.danger { color: var(--vd-danger); } +.vd-button:disabled { opacity: .38; cursor: not-allowed; } + +.history-export-wrap, +.history-columns-wrap { position: relative; } +.history-popover { + position: absolute; + z-index: 45; + top: calc(100% + 7px); + right: 0; + min-width: 190px; + padding: 7px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} +.history-export-menu button, +.history-row-menu button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vd-ink-secondary); + padding: 0 10px; + text-align: left; + font-size: 11px; + cursor: pointer; +} +.history-export-menu button:hover, +.history-row-menu button:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.history-row-menu button.danger { color: var(--vd-danger); } +.history-columns-panel { min-width: 208px; padding: 11px; } +.history-columns-panel strong { display: block; margin-bottom: 8px; font-size: 11px; } +.history-columns-panel label { + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + color: var(--vd-ink-secondary); + font-size: 11px; + cursor: pointer; +} +.history-columns-panel input { accent-color: var(--vd-brand); } + +.history-filterbar { + display: grid; + grid-template-columns: minmax(250px, 1.45fr) repeat(3, minmax(135px, .65fr)) auto auto; + gap: 9px; + align-items: end; + margin-bottom: 14px; +} +.history-search-field, +.history-filter-field, +.history-custom-dates label { + min-height: 52px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); +} +.history-search-field { + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + color: var(--vd-ink-muted); +} +.history-search-field:focus-within, +.history-filter-field:focus-within, +.history-custom-dates label:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.history-search-field input { + width: 100%; + min-width: 0; + height: 48px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 12px; +} +.history-search-field input::placeholder { color: var(--vd-ink-muted); } +.history-filter-field, +.history-custom-dates label { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 6px 11px; +} +.history-filter-field > span, +.history-custom-dates label > span { + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 650; +} +.history-filter-field select, +.history-custom-dates input { + width: 100%; + min-height: 23px; + padding: 0 20px 0 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 11px; + font-weight: 600; +} +.history-custom-dates { display: grid; grid-template-columns: repeat(2, 125px); gap: 7px; } +.history-filterbar > .vd-button { min-height: 52px; } + +.history-table-shell { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + overflow: visible; +} +.history-batch-bar { + min-height: 49px; + margin: 0; + padding: 8px 12px; + border: 0; + border-bottom: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md) var(--vd-radius-md) 0 0; + box-shadow: none; + background: var(--vd-surface-raised); + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; + overflow-x: auto; +} +.history-batch-bar.has-selection { background: color-mix(in srgb, var(--vd-brand-soft) 65%, var(--vd-surface-raised)); } +.history-select-all-wrap { + flex: 0 0 auto; + min-width: 112px; + min-height: 31px; + padding: 0 9px; + border-right: 1px solid var(--vd-line); + color: var(--vd-ink-secondary); +} +.history-select-all-wrap input, +.history-row-select { width: 15px; height: 15px; accent-color: var(--vd-brand); cursor: pointer; } +.history-batch-actions { display: flex; align-items: center; gap: 6px; } +.history-clear-selection { + flex: 0 0 auto; + margin-left: auto; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + cursor: pointer; +} +.history-clear-selection:hover { color: var(--vd-brand); } + +.history-table { + --history-platform: 104px; + --history-provider: 118px; + --history-price: 105px; + --history-status: 125px; + --history-location: 135px; + --history-published: 150px; + min-width: 1080px; + overflow: visible; +} +.history-table-shell { overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--vd-line-strong) transparent; } +.history-table-head, +.history-row { + display: grid; + grid-template-columns: + 34px + minmax(270px, 1.8fr) + var(--history-platform) + var(--history-provider) + var(--history-price) + var(--history-status) + var(--history-location) + var(--history-published) + 40px; + align-items: center; +} +.history-table-head { + min-height: 38px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 680; + letter-spacing: .018em; +} +.history-list { display: block; } +.history-row { + min-height: 70px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface-raised); + transition: background .15s ease; + position: relative; +} +.history-row:last-child { border-bottom: 0; } +.history-row:hover { background: color-mix(in srgb, var(--vd-surface-muted) 68%, transparent); } +.history-row.selected { background: color-mix(in srgb, var(--vd-brand-soft) 72%, var(--vd-surface-raised)); } +.history-cell { + min-width: 0; + padding: 8px 10px; + color: var(--vd-ink-secondary); + font-size: 10px; +} +.history-cell-select { padding-left: 14px; padding-right: 4px; } +.history-cell-article { display: flex; align-items: center; gap: 11px; } +.history-thumb { + width: 56px; + height: 48px; + flex: 0 0 auto; + border-radius: 6px; + object-fit: cover; + background: var(--vd-surface-muted); +} +.history-thumb-placeholder { display: grid; place-items: center; color: var(--vd-ink-muted); } +.history-article-copy { min-width: 0; } +.history-article-title { + display: block; + max-width: 100%; + border: 0; + background: transparent; + color: var(--vd-ink); + padding: 0; + font-size: 11px; + font-weight: 700; + line-height: 1.35; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.history-article-title:hover { color: var(--vd-brand); } +.history-article-sku { + margin-top: 4px; + color: var(--vd-ink-muted); + font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-cell strong { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-cell-sub, +.history-cell small { + display: block; + margin-top: 3px; + color: var(--vd-ink-muted); + font-size: 8.5px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-platform-name { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 760; } +.history-provider-mark { + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--vd-success-soft); + color: var(--vd-success); + font-size: 9px; + font-weight: 800; +} +.history-cell-provider { display: flex; align-items: center; gap: 7px; } +.history-cell-provider span:last-child { min-width: 0; } +.history-status-select { + max-width: 96px; + min-height: 23px; + border: 0; + border-radius: 5px; + padding: 0 20px 0 7px; + font-size: 9px; + font-weight: 720; + cursor: pointer; +} +.history-status-select.status-active { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-reserved { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-date { display: block; color: var(--vd-ink-secondary); font-size: 9px; white-space: nowrap; } +.history-publish-badges { display: flex; gap: 4px; margin-top: 4px; min-width: 0; } +.history-publish-badge { + max-width: 100%; + min-height: 18px; + padding: 2px 6px; + border-radius: 5px; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vd-ink-secondary); + background: var(--vd-surface-muted); + font-size: 8px; + font-weight: 680; + white-space: nowrap; +} +.history-publish-badge span { color: var(--vd-ink); font-weight: 780; } +.history-publish-badge.published { background: var(--vd-success-soft); color: var(--vd-success); } +.history-publish-badge.copied { background: var(--vd-info-soft); color: var(--vd-info); } +.history-publish-badge.pending { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-badge.failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.history-publish-empty { color: var(--vd-ink-muted); font-size: 8.5px; white-space: nowrap; } +.history-cell-menu { position: relative; padding-left: 4px; padding-right: 8px; } +.history-row-menu-button { + width: 30px; + height: 30px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font-size: 10px; + letter-spacing: 1px; + cursor: pointer; +} +.history-row-menu-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-row-menu { + position: absolute; + z-index: 35; + top: 31px; + right: 8px; + width: 145px; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} + +.history-hide-platform { --history-platform: 0px; } +.history-hide-provider { --history-provider: 0px; } +.history-hide-price { --history-price: 0px; } +.history-hide-status { --history-status: 0px; } +.history-hide-location { --history-location: 0px; } +.history-hide-published { --history-published: 0px; } +.history-hide-platform [data-column="platform"], +.history-hide-provider [data-column="provider"], +.history-hide-price [data-column="price"], +.history-hide-status [data-column="status"], +.history-hide-location [data-column="location"], +.history-hide-published [data-column="published"] { display: none; padding: 0; } + +.history-table-footer { + min-height: 55px; + padding: 9px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--vd-line); +} +.history-page-size { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 10px; } +.history-page-size select { + min-height: 30px; + padding: 0 25px 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + font-size: 10px; +} +.history-pagination-nav { display: flex; align-items: center; gap: 5px; } +.history-page-buttons { display: flex; align-items: center; gap: 4px; } +.history-page-button, +.history-pagination-nav .page-nav-btn { + min-width: 31px; + height: 31px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 10px; + cursor: pointer; +} +.history-page-button:hover:not(.active), +.history-pagination-nav .page-nav-btn:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-page-button.active { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.history-pagination-nav .page-nav-btn:disabled { opacity: .3; cursor: not-allowed; } +.history-page-gap { width: 20px; text-align: center; color: var(--vd-ink-muted); font-size: 10px; } +.history-empty-state { + min-height: 360px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 36px; +} +.history-empty-icon { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-muted); } +.history-empty-state h3 { margin-top: 13px; font-size: 14px; } +.history-empty-state p { margin: 5px 0 14px; color: var(--vd-ink-muted); font-size: 11px; } + +/* Detail stays functional while receiving the new surface language. */ +.listings-page .history-detail { + max-width: 1180px; + margin: 0 auto; + border-color: var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + box-shadow: none; +} + +@media (max-width: 1180px) { + .history-filterbar { grid-template-columns: minmax(230px, 1.3fr) repeat(3, minmax(125px, .7fr)); } + .history-filterbar > .vd-button { grid-column: auto; } + .history-custom-dates { grid-column: 1 / -1; justify-content: start; } + .history-table { min-width: 980px; --history-provider: 0px; --history-location: 0px; } + .history-table [data-column="provider"], + .history-table [data-column="location"] { display: none; } +} + +@media (max-width: 860px) { + .listings-heading { flex-direction: column; } + .listings-heading-actions { width: 100%; } + .history-filterbar { grid-template-columns: 1fr 1fr; } + .history-search-field { grid-column: 1 / -1; } + .history-filterbar > .vd-button { min-height: 42px; } + .history-batch-bar { align-items: flex-start; } + .history-batch-actions { min-width: max-content; } +} + +@media (max-width: 520px) { + .listings-heading-actions { overflow-x: auto; padding-bottom: 2px; } + .history-filterbar { grid-template-columns: 1fr; } + .history-search-field, + .history-filter-field { min-height: 48px; } + .history-search-field { grid-column: auto; } + .history-custom-dates { grid-template-columns: 1fr 1fr; } + .history-table-footer { align-items: flex-start; flex-direction: column; } + .history-pagination-nav { width: 100%; justify-content: space-between; } + .history-page-buttons { overflow-x: auto; } +} + +/* --- Slice 03: Studio media fit + in-app Print Studio --- */ +.flow-card-media, +.inspector-hero, +.attention-thumb { + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.flow-card-media { + display: grid; + place-items: center; + padding: 7px; +} +.flow-card-media img, +.inspector-hero img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} +.inspector-hero { + display: grid; + place-items: center; + padding: 12px; +} +.attention-thumb { + object-fit: contain; + object-position: center; + padding: 3px; +} + +body.print-studio-open { overflow: hidden; } +.print-studio { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + padding: 22px; +} +.print-studio-backdrop { + position: absolute; + inset: 0; + background: rgba(22, 22, 19, 0.56); + backdrop-filter: blur(9px); +} +.print-studio-shell { + position: relative; + width: min(1380px, 96vw); + height: min(900px, 94vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 18px; + background: var(--vd-surface-raised); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); +} +.print-studio-header, +.print-studio-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 17px 20px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +.print-studio-header { border-bottom: 1px solid var(--vd-line); } +.print-studio-footer { border-top: 1px solid var(--vd-line); justify-content: flex-end; } +.print-studio-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-brand); + font-size: 9px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.print-studio-header h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; } +.print-studio-close { + width: 36px; + height: 36px; + border: 1px solid var(--vd-line); + border-radius: 50%; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 23px; + line-height: 1; + cursor: pointer; +} +.print-studio-close:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.print-studio-layout { + min-height: 0; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); +} +.print-studio-controls { + padding: 20px; + overflow-y: auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.print-control-group { + display: flex; + align-items: center; + gap: 11px; + padding: 13px; + margin-bottom: 20px; + border: 1px solid var(--vd-line); + border-radius: 11px; + background: var(--vd-surface-raised); +} +.print-control-group > div { min-width: 0; display: grid; gap: 2px; } +.print-control-group strong { font-size: 13px; } +.print-control-group span:not(.print-summary-icon) { color: var(--vd-ink-muted); font-size: 10px; } +.print-summary-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 9px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 18px; + font-weight: 800; +} +.print-control-field { display: grid; gap: 7px; margin-bottom: 16px; } +.print-control-field > span, +.print-control-check > span { color: var(--vd-ink-secondary); font-size: 10px; font-weight: 700; } +.print-control-field select, +.print-control-field input { + width: 100%; + min-height: 40px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + padding: 0 11px; + font: inherit; + font-size: 11px; +} +.print-control-field select:focus, +.print-control-field input:focus { outline: 2px solid var(--vd-brand-soft); border-color: var(--vd-brand); } +.print-control-check { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 0; + cursor: pointer; +} +.print-control-check input { width: 16px; height: 16px; accent-color: var(--vd-brand); } +.print-hint { + display: grid; + gap: 5px; + margin-top: 24px; + padding: 13px; + border-left: 3px solid var(--vd-brand); + background: var(--vd-brand-soft); +} +.print-hint strong { font-size: 11px; } +.print-hint span { color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.5; } +.print-preview-pane { + min-width: 0; + overflow: auto; + padding: 0 24px 36px; + background: + linear-gradient(90deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + linear-gradient(0deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + var(--vd-surface-muted); + background-size: 24px 24px; +} +.print-preview-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 -24px 24px; + padding: 12px 24px; + border-bottom: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-muted) 90%, transparent); + backdrop-filter: blur(10px); + color: var(--vd-ink-muted); + font-size: 10px; +} +.print-preview-toolbar span:first-child { color: var(--vd-ink); font-weight: 700; } +.print-preview-pages { display: grid; justify-items: center; gap: 28px; } +.print-page { + width: 794px; + min-height: 1123px; + padding: 38px; + overflow: hidden; + background: #fff; + color: #1f1f1c; + box-shadow: 0 14px 40px rgba(31, 31, 28, .18); +} +.print-label-grid { display: grid; width: 100%; height: 100%; align-content: start; } +.standard-labels .print-label-grid { grid-template-columns: repeat(2, 340px); grid-auto-rows: 177px; gap: 23px 34px; } +.compact-labels .print-label-grid { grid-template-columns: repeat(3, 217px); grid-auto-rows: 101px; gap: 8px 18px; } +.print-label-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 15px; + overflow: hidden; + border: 1px solid #d8d5ce; + background: #fff; + padding: 15px 16px 15px 21px; +} +.print-label-accent { position: absolute; inset: 0 auto 0 0; width: 5px; background: #e33a16; } +.print-label-qr img { display: block; width: 106px; height: 106px; } +.print-label-copy { min-width: 0; display: grid; align-content: center; gap: 4px; } +.print-label-brand { color: #e33a16; font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.print-label-sku { font-size: 21px; font-weight: 850; letter-spacing: -.035em; } +.print-label-title { min-height: 31px; overflow: hidden; font-size: 11px; font-weight: 650; line-height: 1.35; } +.print-label-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #6d6961; font-size: 9px; } +.print-label-meta strong { color: #1f1f1c; font-size: 12px; } +.compact-labels .print-label-card { gap: 7px; padding: 7px 8px 7px 11px; } +.compact-labels .print-label-accent { width: 3px; } +.compact-labels .print-label-qr img { width: 62px; height: 62px; } +.compact-labels .print-label-brand { font-size: 6px; } +.compact-labels .print-label-sku { font-size: 12px; } +.compact-labels .print-label-title { min-height: 20px; font-size: 7px; } +.compact-labels .print-label-meta { font-size: 6px; } +.compact-labels .print-label-meta strong { font-size: 8px; } +.print-datasheet { min-height: 100%; display: grid; align-content: start; gap: 24px; } +.print-datasheet-header { display: flex; justify-content: space-between; gap: 30px; padding-bottom: 22px; border-bottom: 4px solid #e33a16; } +.print-datasheet-brand { display: block; margin-bottom: 7px; color: #e33a16; font-size: 10px; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; } +.print-datasheet h1 { max-width: 570px; margin: 0; font-size: 29px; line-height: 1.15; letter-spacing: -.04em; } +.print-datasheet-sku { margin-top: 9px; color: #5f5b54; font-size: 16px; font-weight: 750; } +.print-datasheet-qr { width: 118px; height: 118px; } +.print-datasheet-photos { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.print-datasheet-photos > div { aspect-ratio: 1; display: grid; place-items: center; border: 1px solid #dfdcd5; background: #f5f3ee; padding: 8px; } +.print-datasheet-photos img { width: 100%; height: 100%; object-fit: contain; } +.print-datasheet-description { padding: 17px 19px; border-left: 4px solid #e33a16; background: #f5f3ee; } +.print-datasheet-description h2 { margin: 0 0 7px; font-size: 13px; } +.print-datasheet-description p { margin: 0; color: #45423d; font-size: 11px; line-height: 1.65; } +.print-datasheet-meta { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid #dfdcd5; } +.print-datasheet-meta > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; padding: 11px 0; border-bottom: 1px solid #dfdcd5; } +.print-datasheet-meta > div:nth-child(odd) { padding-right: 20px; } +.print-datasheet-meta > div:nth-child(even) { padding-left: 20px; border-left: 1px solid #dfdcd5; } +.print-datasheet-meta span { color: #77726a; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } +.print-datasheet-meta strong { font-size: 11px; } + +@media (max-width: 1050px) { + .print-studio { padding: 10px; } + .print-studio-shell { width: 100%; height: 100%; border-radius: 12px; } + .print-studio-layout { grid-template-columns: 220px minmax(0, 1fr); } + .print-page { transform: scale(.78); transform-origin: top center; margin-bottom: -247px; } +} + +@media (max-width: 720px) { + .print-studio-layout { grid-template-columns: 1fr; } + .print-studio-controls { border-right: 0; border-bottom: 1px solid var(--vd-line); max-height: 220px; } + .print-preview-pane { min-height: 0; } + .print-page { transform: scale(.55); margin-bottom: -505px; } +} + +@media print { + @page { size: A4 portrait; margin: 0; } + body.print-studio-active { display: block !important; background: #fff !important; } + body.print-studio-active > *:not(#print-studio) { display: none !important; } + body.print-studio-active #print-studio { + position: static !important; + display: block !important; + padding: 0 !important; + background: #fff !important; + } + body.print-studio-active .print-studio-backdrop, + body.print-studio-active .print-studio-header, + body.print-studio-active .print-studio-controls, + body.print-studio-active .print-preview-toolbar, + body.print-studio-active .print-studio-footer { display: none !important; } + body.print-studio-active .print-studio-shell, + body.print-studio-active .print-studio-layout, + body.print-studio-active .print-preview-pane, + body.print-studio-active .print-preview-pages { + display: block !important; + width: auto !important; + height: auto !important; + overflow: visible !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + box-shadow: none !important; + background: #fff !important; + } + body.print-studio-active .print-page { + width: 210mm !important; + min-height: 297mm !important; + padding: 10mm !important; + margin: 0 !important; + transform: none !important; + box-shadow: none !important; + break-after: page; + page-break-after: always; + } + body.print-studio-active .print-page:last-child { break-after: auto; page-break-after: auto; } + body.print-studio-active .standard-labels .print-label-grid { + grid-template-columns: repeat(2, 90mm) !important; + grid-auto-rows: 50mm !important; + gap: 5mm 5mm !important; + } + body.print-studio-active .compact-labels .print-label-grid { + grid-template-columns: repeat(3, 62mm) !important; + grid-auto-rows: 29mm !important; + gap: 1.6mm 2mm !important; + } +} + +/* ========================================================================== + Listing Studio 2026 + ========================================================================== */ +.generator-studio-page { min-width: 0; } +.generator-heading { align-items: center; } +.generator-heading-actions { display: flex; align-items: center; gap: 12px; } +.generator-stepper { display: flex; align-items: center; gap: 6px; } +.generator-step { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 999px; + color: var(--vd-ink-muted); + background: var(--vd-surface-raised); + font-size: 10px; + font-weight: 680; + white-space: nowrap; +} +.generator-step b { + width: 17px; + height: 17px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; +} +.generator-step.is-active { border-color: var(--vd-brand); color: var(--vd-brand); } +.generator-step.is-active b { background: var(--vd-brand); color: white; } +.generator-step.is-complete { border-color: var(--vd-success); color: var(--vd-success); } +.generator-step.is-complete b { background: var(--vd-success); color: white; } + +.generator-studio-layout { + display: grid; + grid-template-columns: minmax(260px, .82fr) minmax(310px, 1fr) minmax(350px, 1.18fr); + gap: 12px; + align-items: start; +} +.generator-panel { + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + overflow: hidden; +} +.generator-panel-header { + min-height: 58px; + padding: 11px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--vd-line); +} +.generator-panel-header > div { display: flex; align-items: center; gap: 9px; min-width: 0; } +.generator-panel-header h3 { font-size: 12px; letter-spacing: -0.025em; } +.generator-panel-header p { margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.35; } +.generator-panel-index { + width: 27px; + height: 27px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-brand); + font: 720 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.generator-count, .generator-beta, .generator-preview-status { + min-height: 22px; + padding: 3px 8px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 720; + white-space: nowrap; +} +.generator-beta { background: var(--vd-brand-soft); color: var(--vd-brand); } +.generator-preview-status.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.generator-preview-status.is-working { background: var(--vd-warning-soft); color: var(--vd-warning); } + +.media-workbench { padding-bottom: 12px; } +.generator-photo-stage { + position: relative; + aspect-ratio: 1.2; + margin: 12px 12px 9px; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: + linear-gradient(45deg, var(--vd-surface-muted) 25%, transparent 25%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 9px -9px/18px 18px, + linear-gradient(45deg, var(--vd-surface-muted) 25%, var(--vd-surface) 25%) 9px -9px/18px 18px; +} +.generator-cover { width: 100%; height: 100%; display: block; object-fit: contain; object-position: center; padding: 8px; } +.generator-photo-empty { + width: 100%; + height: 100%; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 26px; + color: var(--vd-ink-muted); +} +.generator-photo-empty-mark { + width: 58px; + height: 58px; + display: grid; + place-items: center; + margin-bottom: 12px; + border-radius: 50%; + background: var(--vd-surface-raised); + border: 1px solid var(--vd-line); + color: var(--vd-brand); +} +.generator-photo-empty strong { color: var(--vd-ink); font-size: 12px; } +.generator-photo-empty span { max-width: 220px; margin-top: 6px; font-size: 10px; line-height: 1.5; } +.generator-cover-badge { + position: absolute; + left: 9px; + bottom: 9px; + min-height: 23px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--vd-ink) 82%, transparent); + color: var(--vd-surface); + font-size: 9px; + font-weight: 720; + backdrop-filter: blur(8px); +} +.generator-dropzone { + margin: 0 12px; + min-height: 92px; + padding: 13px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + text-align: left; + border-width: 1px; + border-radius: 8px; + background: var(--vd-surface); +} +.generator-dropzone-icon { + grid-row: 1 / 3; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + background: var(--vd-brand-soft); + color: var(--vd-brand); +} +.generator-dropzone p { font-size: 10px; line-height: 1.45; color: var(--vd-ink-secondary); } +.generator-dropzone p strong { color: var(--vd-ink); font-size: 11px; } +.generator-dropzone small { color: var(--vd-ink-muted); font-size: 8px; } +.generator-photo-strip { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin: 9px 12px 0; +} +.generator-photo-strip .thumb { + border: 1px solid var(--vd-line); + border-radius: 6px; + box-shadow: none; + background: var(--vd-surface-muted); +} +.generator-photo-strip .thumb:first-child { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.generator-photo-strip .thumb img { object-fit: contain; object-position: center; padding: 2px; } +.generator-photo-strip .thumb::after { + content: attr(data-index); + position: absolute; + left: 4px; + bottom: 4px; + min-width: 15px; + height: 15px; + display: grid; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--vd-ink) 76%, transparent); + color: var(--vd-surface); + font-size: 7px; + font-weight: 760; +} +.generator-image-tools { margin: 8px 12px 0; } +.generator-image-tools .small-btn { display: inline-flex; align-items: center; gap: 6px; } +.generator-image-tools .advanced-editor-entry { + background: var(--vd-brand); + color: #fff; + border-color: var(--vd-brand); + font-weight: 760; + box-shadow: 0 5px 14px color-mix(in srgb, var(--vd-brand) 22%, transparent); +} +.generator-image-tools .advanced-editor-entry:hover { + background: var(--vd-brand-strong, var(--vd-brand)); + color: #fff; + transform: translateY(-1px); +} + +.generator-photo-quality { + margin: 11px 12px 0; + padding: 11px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-photo-quality-head { display: flex; align-items: center; justify-content: space-between; } +.generator-photo-quality-head > div { display: flex; align-items: center; gap: 6px; } +.generator-photo-quality-head strong { font-size: 10px; } +.generator-photo-quality-head > span { color: var(--vd-ink-muted); font-size: 9px; font-weight: 700; } +.quality-spark { color: var(--vd-brand); } +.generator-photo-quality ul { list-style: none; margin-top: 8px; display: grid; gap: 6px; } +.generator-photo-quality li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.generator-photo-quality li > span { + width: 13px; + height: 13px; + border: 1px solid var(--vd-line-strong); + border-radius: 50%; + flex: 0 0 auto; +} +.generator-photo-quality li.is-complete { color: var(--vd-ink-secondary); } +.generator-photo-quality li.is-complete > span { border-color: var(--vd-success); background: var(--vd-success); position: relative; } +.generator-photo-quality li.is-complete > span::after { content: "✓"; position: absolute; inset: 0; display: grid; place-items: center; color: white; font-size: 8px; } + +.generator-config-panel { position: relative; } +.generator-config-stack { padding: 10px; display: grid; gap: 7px; } +.generator-config-card, .generator-notes-card { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-config-card { display: grid; gap: 8px; } +.generator-config-card.compact { align-content: start; } +.generator-config-copy label, .generator-notes-head label { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 720; text-transform: none; letter-spacing: 0; margin: 0; } +.generator-config-copy span, .generator-notes-head span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.4; } +.generator-choice-group { gap: 5px; } +.generator-choice-group .toggle-btn { + min-height: 28px; + padding: 0 9px; + border-width: 1px; + border-radius: 7px; + font-size: 9px; +} +.generator-choice-group .toggle-btn.active { background: var(--vd-brand); border-color: var(--vd-brand); } +.generator-config-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.generator-config-card select { min-height: 34px; padding: 6px 9px; font-size: 10px; } +.generator-template-tags { display: flex; gap: 4px; flex-wrap: wrap; } +.generator-template-tags span { + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 999px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 8px; + font-weight: 680; +} +.generator-notes-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.generator-notes-head > span { white-space: nowrap; } +.generator-notes-card textarea { + width: 100%; + margin-top: 8px; + min-height: 88px; + resize: vertical; + padding: 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + color: var(--vd-ink); + background: var(--vd-surface-raised); + font: 10px/1.55 inherit; +} +.generator-tone-row { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 7px; } +.generator-tone-row span { padding: 3px 6px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 7px; } +.generator-loading { margin: 0 10px 10px; padding: 18px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.generator-loading p { line-height: 1.5; } +.generator-loading p strong { color: var(--vd-ink); } +.generator-loading p span { font-size: 9px; } +.generator-action-zone { + position: sticky; + bottom: 0; + display: grid; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-raised) 94%, transparent); + backdrop-filter: blur(10px); +} +.generator-readiness { display: grid; grid-template-columns: 9px minmax(0, 1fr); gap: 8px; align-items: center; } +.generator-readiness > span { width: 7px; height: 7px; border-radius: 50%; background: var(--vd-warning); } +.generator-readiness.is-ready > span { background: var(--vd-success); } +.generator-readiness strong { display: block; font-size: 9px; } +.generator-readiness small { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; } +.generator-primary-action { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 11px; } + +.generator-preview-panel { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + scrollbar-width: thin; +} +.generator-preview-empty { min-height: 520px; display: grid; align-content: center; justify-items: center; text-align: center; padding: 34px; color: var(--vd-ink-muted); } +.generator-preview-empty-mark { + width: 62px; + height: 62px; + display: grid; + place-items: center; + border-radius: 18px 18px 18px 5px; + background: var(--vd-brand); + color: white; + font-size: 17px; + font-weight: 820; + letter-spacing: -.08em; + box-shadow: 0 12px 24px var(--vd-brand-soft); +} +.generator-preview-empty h3 { margin-top: 15px; color: var(--vd-ink); font-size: 14px; } +.generator-preview-empty p { max-width: 275px; margin-top: 7px; font-size: 10px; line-height: 1.55; } +.generator-preview-skeleton { width: min(280px, 100%); margin-top: 20px; display: grid; gap: 7px; } +.generator-preview-skeleton span { height: 9px; border-radius: 999px; background: var(--vd-surface-muted); } +.generator-preview-skeleton span:nth-child(2) { width: 82%; } +.generator-preview-skeleton span:nth-child(3) { width: 94%; } +.generator-preview-skeleton span:nth-child(4) { width: 58%; } +.generator-variant-wrap { padding: 10px 12px 0; } +.generator-section-label { color: var(--vd-ink-muted); font-size: 8px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.generator-result { margin: 0; padding: 12px; border: 0; border-radius: 0; background: transparent; } +.generator-result-score { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-result-score > div:first-child span { display: block; color: var(--vd-ink-muted); font-size: 8px; } +.generator-result-score > div:first-child strong { display: block; margin-top: 2px; color: var(--vd-success); font-size: 11px; } +.generator-quality-ring { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 50%; + background: conic-gradient(var(--vd-success) var(--quality), var(--vd-surface-muted) 0); + position: relative; +} +.generator-quality-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface); } +.generator-quality-ring span { position: relative; z-index: 1; font-size: 9px; font-weight: 780; } +.generator-result .result-field { margin-bottom: 11px; } +.generator-result .field-header > div { display: flex; align-items: center; gap: 6px; } +.generator-result .field-header label { color: var(--vd-ink-secondary); font-size: 9px; } +.field-count { color: var(--vd-ink-muted); font-size: 8px; } +.generator-result .result-input { padding: 8px 9px; font-size: 11px; } +.generator-result .ql-toolbar.ql-snow { border-color: var(--vd-line); border-radius: 7px 7px 0 0; background: var(--vd-surface); } +.generator-result .ql-container.ql-snow { min-height: 190px; border-color: var(--vd-line); border-radius: 0 0 7px 7px; font-size: 11px; } +.generator-meta-section { margin: 12px 0 0; padding: 10px; border-radius: 8px; background: var(--vd-surface); } +.generator-meta-section .meta-grid-edit { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 8px; } +.generator-meta-section .meta-category-item { grid-column: 1 / -1; } +.generator-meta-section .meta-label { font-size: 8px; } +.generator-meta-section .meta-input { padding: 7px 8px; font-size: 10px; } +.generator-meta-section .fee-info { padding-top: 8px; font-size: 9px; } +.generator-meta-section .fee-info .fee-net { font-size: 11px; } +.generator-html-details { margin-top: 11px; border: 1px solid var(--vd-line); border-radius: 8px; overflow: hidden; } +.generator-html-details summary { padding: 10px; cursor: pointer; color: var(--vd-ink-secondary); font-size: 9px; font-weight: 680; } +.generator-html-details summary span { color: var(--vd-ink-muted); font-weight: 500; } +.generator-html-actions { display: flex; justify-content: flex-end; gap: 5px; padding: 0 10px 8px; } +.generator-html-details .html-preview, .generator-html-details .html-source { margin: 0 10px 10px; width: calc(100% - 20px); } +.generator-result-actions { position: sticky; bottom: -12px; display: grid; grid-template-columns: auto 1fr; padding: 10px 0 0; background: var(--vd-surface-raised); } +.generator-result-actions button { min-height: 38px; } + +@media (max-width: 1280px) { + .generator-studio-layout { grid-template-columns: minmax(250px, .8fr) minmax(310px, 1fr); } + .generator-preview-panel { grid-column: 1 / -1; position: static; max-height: none; } + .generator-preview-empty { min-height: 260px; } +} + +@media (max-width: 900px) { + .generator-heading { align-items: flex-start; flex-direction: column; } + .generator-heading-actions { width: 100%; justify-content: space-between; } + .generator-studio-layout { grid-template-columns: 1fr; } + .generator-preview-panel { grid-column: auto; } +} + +@media (max-width: 560px) { + .generator-stepper { width: 100%; } + .generator-step { flex: 1; justify-content: center; padding: 0 5px; } + .generator-heading-actions { align-items: stretch; flex-direction: column; } + .generator-config-pair { grid-template-columns: 1fr; } + .generator-photo-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .generator-meta-section .meta-grid-edit { grid-template-columns: 1fr; } + .generator-meta-section .meta-category-item { grid-column: auto; } +} + +/* --- Slice 05: Publish Center 2026 --- */ +.publish-center-page { padding-bottom: 22px; } +.publish-center-heading { align-items: flex-end; } +.publish-heading-actions { display: flex; align-items: center; gap: 7px; } +.publish-view-tabs { + display: flex; + align-items: flex-end; + gap: 2px; + margin: 10px 0 0; + padding: 0 2px; + overflow-x: auto; + border-bottom: 1px solid var(--vd-line); + scrollbar-width: none; +} +.publish-view-tabs::-webkit-scrollbar { display: none; } +.publish-view-tab { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 680; + white-space: nowrap; + cursor: pointer; +} +.publish-view-tab:hover { color: var(--vd-ink); background: var(--vd-surface-muted); } +.publish-view-tab.active { color: var(--vd-ink); border-bottom-color: var(--vd-brand); background: var(--vd-surface-raised); } +.publish-tab-count, +.publish-tab-badge { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 5px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 8px; + font-weight: 760; +} +.publish-tab-badge { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-view-tab.active .publish-tab-count { background: var(--vd-brand-soft); color: var(--vd-brand); } + +.publish-center-grid { + min-height: 680px; + display: grid; + grid-template-columns: 264px minmax(0, 1fr) 282px; + border: 1px solid var(--vd-line); + border-top: 0; + border-radius: 0 0 var(--vd-radius-md) var(--vd-radius-md); + overflow: hidden; + background: var(--vd-surface-raised); +} +.publish-list-panel, +.publish-status-rail, +.publish-workspace-panel { min-width: 0; min-height: 0; } +.publish-list-panel { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-list-panel-head, +.publish-rail-heading, +.publish-operation-header, +.publish-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.publish-list-panel-head { padding: 14px 14px 11px; } +.publish-panel-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-ink-muted); + font-size: 7.5px; + font-weight: 780; + letter-spacing: .1em; + text-transform: uppercase; +} +.publish-list-panel-head h3, +.publish-rail-heading h3, +.publish-operation-header h3, +.publish-workspace-header h3 { margin: 0; color: var(--vd-ink); font-size: 13px; letter-spacing: -.015em; } +.publish-list-panel-head h3 span { color: var(--vd-ink-muted); font-weight: 560; } +.publish-list-panel-head select { + max-width: 112px; + min-height: 30px; + padding: 0 24px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-list-filters { padding: 0 12px 11px; display: grid; gap: 7px; border-bottom: 1px solid var(--vd-line); } +.publish-search-field { + min-height: 36px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); +} +.publish-search-field:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.publish-search-field input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--vd-ink); font: 9px/1 inherit; } +.publish-filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.publish-filter-row select { + min-width: 0; + min-height: 32px; + padding: 0 22px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-selection-bar { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 8px; +} +.publish-selection-bar label { display: inline-flex; align-items: center; gap: 6px; color: var(--vd-ink-secondary); cursor: pointer; } +.publish-selection-bar input, +.publish-card-check input { accent-color: var(--vd-brand); } +.publish-center-page .publish-list { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px; + overflow-y: auto; + scrollbar-width: thin; +} +.publish-list-card { + position: relative; + display: grid; + grid-template-columns: 16px 54px minmax(0, 1fr); + gap: 8px; + align-items: center; + min-height: 76px; + padding: 8px; + border: 1px solid transparent; + border-radius: 9px; + background: var(--vd-surface-raised); + cursor: pointer; + transition: border-color .16s ease, background .16s ease, transform .16s ease; +} +.publish-list-card:hover { border-color: var(--vd-line-strong); transform: translateY(-1px); } +.publish-list-card.is-active { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.publish-card-check { align-self: start; padding-top: 4px; cursor: pointer; } +.publish-card-media { + width: 54px; + height: 58px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-card-media img { width: 100%; height: 100%; object-fit: contain; object-position: center; padding: 3px; } +.publish-card-copy { min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-right: 4px; } +.publish-card-copy > strong { color: var(--vd-ink); font-size: 9px; line-height: 1.25; font-weight: 720; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.publish-card-sku { margin-top: 3px; color: var(--vd-ink-muted); font: 7.5px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-card-meta { margin-top: 7px; display: flex; align-items: center; justify-content: space-between; gap: 7px; color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-card-meta b { color: var(--vd-ink); font-size: 8.5px; font-weight: 720; } +.publish-card-status { + position: absolute; + right: 7px; + bottom: 7px; + min-height: 17px; + display: inline-flex; + align-items: center; + padding: 0 5px; + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 720; +} +.publish-card-status.status-published { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-card-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-card-status.status-pending, +.publish-card-status.status-uploading { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-card-status.status-copied { background: var(--vd-info-soft); color: var(--vd-info); } +.publish-card-status.status-prepared { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-batch-bar { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 9px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-batch-bar .vd-button { flex: 1 1 auto; min-width: 68px; } + +.publish-workspace-panel { background: var(--vd-surface-raised); } +.publish-workspace { min-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; } +.publish-workspace-header { + min-height: 68px; + padding: 11px 14px; + border-bottom: 1px solid var(--vd-line); +} +.publish-workspace-product { min-width: 0; display: flex; align-items: center; gap: 10px; } +.publish-workspace-thumb { + width: 42px; + height: 42px; + flex: 0 0 auto; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-workspace-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 2px; } +.publish-workspace-product > div:last-child { min-width: 0; } +.publish-workspace-product h3 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.publish-workspace-product p { margin-top: 3px; color: var(--vd-ink-muted); font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-workspace-header-actions { display: flex; align-items: center; gap: 5px; } +.publish-workspace-state { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 999px; background: var(--vd-success-soft); color: var(--vd-success); font-size: 8px; font-weight: 720; } +.icon-button { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + cursor: pointer; +} +.icon-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.icon-button.danger { color: var(--vd-danger); } +.icon-button.is-loading, +.vd-button.is-loading { opacity: .72; cursor: wait; pointer-events: none; } +@keyframes vd-publish-spin { to { transform: rotate(360deg); } } +.publish-workspace-loading, +.publish-workspace-empty, +.publish-workspace-error { + min-height: 440px; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 32px; + text-align: center; + color: var(--vd-ink-muted); +} +.publish-workspace-loading span { width: 22px; height: 22px; border: 2px solid var(--vd-line); border-top-color: var(--vd-brand); border-radius: 50%; animation: vd-publish-spin .8s linear infinite; } +.publish-workspace-empty > span, +.publish-workspace-error > span { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 14px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; } +.publish-workspace-empty h3, +.publish-workspace-error strong { color: var(--vd-ink); font-size: 13px; } +.publish-workspace-empty p, +.publish-workspace-error p { max-width: 340px; font-size: 9px; line-height: 1.55; } +.publish-workspace-empty.compact { min-height: 320px; } +.publish-smart-grid, +.publish-direct-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 226px; + overflow-y: auto; +} +.publish-editor-column { min-width: 0; padding: 13px; display: grid; align-content: start; gap: 10px; } +.publish-editor-section { padding-bottom: 10px; border-bottom: 1px solid var(--vd-line); } +.publish-editor-section:last-child { border-bottom: 0; } +.publish-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } +.publish-field-head label { color: var(--vd-ink-secondary); font-size: 8.5px; font-weight: 720; } +.publish-field-head span { color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-field-head > div { display: flex; gap: 4px; } +.publish-field-head button, +.publish-copy-field button { + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 7.5px; + font-weight: 680; + cursor: pointer; +} +.publish-copy-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; } +.publish-copy-field input, +.publish-editor-section > input, +.publish-editor-section textarea, +.publish-meta-form input { + width: 100%; + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: 9px/1.45 inherit; +} +.publish-copy-field input, +.publish-editor-section > input, +.publish-meta-form input { min-height: 34px; padding: 0 9px; } +.publish-editor-section textarea { min-height: 190px; padding: 9px; resize: vertical; } +.publish-copy-field input[readonly], +.publish-editor-section textarea[readonly] { color: var(--vd-ink-secondary); background: var(--vd-surface-muted); } +.publish-meta-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; } +.publish-meta-form.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.publish-meta-form label { min-width: 0; display: grid; gap: 4px; } +.publish-meta-form label > span { color: var(--vd-ink-muted); font-size: 7.5px; font-weight: 680; } +.publish-meta-form.compact .publish-copy-field { grid-template-columns: minmax(0, 1fr); } +.publish-meta-form.compact .publish-copy-field button { display: none; } +.publish-readiness-column { + min-width: 0; + padding: 13px; + display: grid; + align-content: start; + gap: 10px; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-readiness-card, +.publish-photo-section { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-raised); +} +.publish-readiness-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.publish-readiness-head h4 { margin: 0; font-size: 12px; } +.publish-platform-wordmark { color: var(--vd-brand); font-size: 9px; font-weight: 800; } +.integration-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--vd-danger); box-shadow: 0 0 0 4px var(--vd-danger-soft); } +.integration-dot.is-connected { background: var(--vd-success); box-shadow: 0 0 0 4px var(--vd-success-soft); } +.publish-integration-message { margin: 8px 0 4px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.publish-readiness-card ul { list-style: none; margin: 10px 0; display: grid; gap: 6px; } +.publish-readiness-card li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-secondary); font-size: 8.5px; } +.publish-readiness-card li > span { width: 15px; height: 15px; display: grid; place-items: center; border-radius: 50%; font-size: 8px; font-weight: 800; } +.publish-readiness-card li.is-ready > span { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-readiness-card li.is-missing > span { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message { padding: 9px; border-radius: 7px; background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-ready-message strong, +.publish-ready-message small { display: block; } +.publish-ready-message strong { font-size: 9px; } +.publish-ready-message small { margin-top: 2px; color: inherit; opacity: .8; font-size: 7.5px; line-height: 1.4; } +.publish-photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } +.publish-photo-grid img, +.publish-photo-grid > span { + width: 100%; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; + object-fit: contain; + padding: 2px; +} +.publish-no-photos { min-height: 70px; display: grid; place-items: center; border: 1px dashed var(--vd-line-strong); border-radius: 7px; color: var(--vd-ink-muted); font-size: 8px; } +.vd-button.full { width: 100%; margin-top: 7px; justify-content: center; } +.publish-workspace-footer { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 13px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-workspace-footer > div { display: flex; gap: 6px; } + +.publish-status-rail { + overflow-y: auto; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); + scrollbar-width: thin; +} +.publish-rail-section { padding: 14px; border-bottom: 1px solid var(--vd-line); } +.publish-rail-heading { margin-bottom: 10px; } +.publish-status-list { display: grid; } +.publish-status-row { + width: 100%; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--vd-line); + background: transparent; + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-status-row:last-child { border-bottom: 0; } +.publish-status-row:hover strong { color: var(--vd-brand); } +.publish-status-icon { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--vd-line); border-radius: 50%; font-size: 9px; font-weight: 800; } +.publish-status-icon.pending { color: var(--vd-ink-muted); } +.publish-status-icon.processing { color: var(--vd-info); border-color: var(--vd-info); } +.publish-status-icon.success { color: var(--vd-success); border-color: var(--vd-success); } +.publish-status-icon.danger { color: var(--vd-danger); border-color: var(--vd-danger); } +.publish-status-icon.scheduled { color: var(--vd-warning); border-color: var(--vd-warning); } +.publish-status-row strong, +.publish-status-row small { display: block; } +.publish-status-row strong { font-size: 8.5px; } +.publish-status-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-status-row b { color: var(--vd-ink); font-size: 10px; } +.publish-quick-actions { display: grid; gap: 7px; } +.publish-quick-action { + width: 100%; + min-height: 45px; + display: grid; + align-content: center; + padding: 8px 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-quick-action:hover { border-color: var(--vd-line-strong); } +.publish-quick-action.primary { border-color: var(--vd-brand); background: var(--vd-brand); color: white; } +.publish-quick-action strong, +.publish-quick-action small { display: block; } +.publish-quick-action strong { font-size: 8.5px; } +.publish-quick-action small { margin-top: 2px; color: inherit; opacity: .72; font-size: 7px; } +.publish-activity-list { display: grid; gap: 8px; } +.publish-activity-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; } +.publish-activity-icon { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; } +.publish-activity-icon.type-published, +.publish-activity-icon.type-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-activity-icon.type-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-activity-icon.type-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-activity-row strong, +.publish-activity-row small { display: block; } +.publish-activity-row strong { color: var(--vd-ink-secondary); font-size: 7.8px; line-height: 1.35; font-weight: 620; } +.publish-activity-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-rail-empty { color: var(--vd-ink-muted); font-size: 8px; } +.publish-empty-state { min-height: 180px; display: grid; place-content: center; justify-items: center; gap: 4px; padding: 20px; text-align: center; } +.publish-empty-state strong { color: var(--vd-ink); font-size: 10px; } +.publish-empty-state span { color: var(--vd-ink-muted); font-size: 8px; } + +.publish-operation-header { + min-height: 74px; + padding: 13px 15px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-header p { margin-top: 3px; color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-header > div:last-child { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; } +.publish-operation-list, +.publish-schedule-list { min-height: 0; padding: 10px 13px; overflow-y: auto; } +.publish-operation-row { + min-height: 64px; + display: grid; + grid-template-columns: minmax(180px, 1.5fr) 100px 64px minmax(100px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-row:hover { background: var(--vd-surface); } +.publish-operation-product { min-width: 0; display: flex; align-items: center; gap: 8px; } +.publish-operation-mark { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 8px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-product strong, +.publish-operation-product small { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-product strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-operation-product small { margin-top: 3px; color: var(--vd-ink-muted); font: 7px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-meta span, +.publish-operation-meta b { display: block; } +.publish-operation-meta span { color: var(--vd-ink-muted); font-size: 7px; } +.publish-operation-meta b { margin-top: 3px; color: var(--vd-ink); font-size: 8px; } +.operation-status { display: inline-flex !important; width: fit-content; min-height: 19px; align-items: center; padding: 0 6px; border-radius: 5px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary) !important; } +.operation-status.status-published { background: var(--vd-success-soft); color: var(--vd-success) !important; } +.operation-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger) !important; } +.operation-status.status-uploading, +.operation-status.status-pending, +.operation-status.status-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning) !important; } +.publish-operation-error { min-width: 0; color: var(--vd-ink-muted); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } +.publish-operation-footer { min-height: 55px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 13px; border-top: 1px solid var(--vd-line); color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-footer strong { color: var(--vd-ink); } +.publish-schedule-row { + min-height: 62px; + display: grid; + grid-template-columns: 135px minmax(190px, 1fr) 90px minmax(80px, .7fr) 30px; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-schedule-date strong, +.publish-schedule-date span { display: block; } +.publish-schedule-date strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-schedule-date span { margin-top: 3px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-schedule-error { min-width: 0; color: var(--vd-danger); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.publish-schedule-dialog { width: min(620px, 94vw); max-width: 620px; } +.publish-schedule-summary { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 13px; } +.publish-schedule-summary span, +.publish-schedule-summary b { padding: 4px 7px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 7.5px; font-weight: 620; } +.publish-schedule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.publish-schedule-grid label { display: grid; gap: 5px; color: var(--vd-ink-secondary); font-size: 8px; font-weight: 680; } +.publish-schedule-grid select, +.publish-schedule-grid input { min-height: 40px; padding: 0 10px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface-raised); color: var(--vd-ink); font-size: 9px; } +.publish-schedule-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 16px; } + +@media (max-width: 1320px) { + .publish-center-grid { grid-template-columns: 245px minmax(0, 1fr); } + .publish-status-rail { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr 1fr; border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-rail-section { border-bottom: 0; border-right: 1px solid var(--vd-line); } + .publish-rail-section:last-child { border-right: 0; } +} +@media (max-width: 960px) { + .publish-center-heading { align-items: flex-start; flex-direction: column; } + .publish-center-grid { grid-template-columns: 1fr; } + .publish-list-panel { max-height: 470px; border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-smart-grid, + .publish-direct-grid { grid-template-columns: 1fr; } + .publish-readiness-column { border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-status-rail { grid-template-columns: 1fr; } + .publish-rail-section { border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-operation-row { grid-template-columns: minmax(170px, 1fr) 90px 60px auto; } + .publish-operation-error { display: none; } +} +@media (max-width: 620px) { + .publish-heading-actions { width: 100%; } + .publish-heading-actions .vd-button { flex: 1; } + .publish-view-tab { padding: 0 10px; } + .publish-list-panel-head { align-items: flex-start; } + .publish-filter-row, + .publish-schedule-grid, + .publish-meta-form, + .publish-meta-form.compact { grid-template-columns: 1fr; } + .publish-workspace-header { align-items: flex-start; } + .publish-workspace-header-actions { flex-wrap: wrap; justify-content: flex-end; } + .publish-workspace-footer, + .publish-operation-footer { align-items: stretch; flex-direction: column; } + .publish-workspace-footer > div { display: grid; grid-template-columns: 1fr 1fr; } + .publish-workspace-footer .vd-button { justify-content: center; } + .publish-operation-header { align-items: flex-start; flex-direction: column; } + .publish-operation-header > div:last-child { width: 100%; justify-content: stretch; } + .publish-operation-header .vd-button { flex: 1; } + .publish-operation-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .publish-operation-meta, + .publish-operation-error { display: none; } + .publish-schedule-row { grid-template-columns: 1fr auto; } + .publish-schedule-row > :nth-child(3), + .publish-schedule-row > :nth-child(4) { display: none; } +} + +/* ========================================================================== + Slice 06 — Full Listing Editor + Live WYSIWYG/HTML synchronization + ========================================================================== */ +.generator-html-workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + padding: 0 10px 10px; +} +.generator-html-pane { + min-width: 0; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); +} +.generator-html-pane-head, +.detail-pane-head, +.detail-html-preview-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.generator-html-pane-head strong, +.detail-pane-head strong, +.detail-html-preview-head strong { color: var(--vd-ink); font-size: 8px; } +.generator-html-pane-head span, +.detail-pane-head span, +.detail-html-preview-head span { color: var(--vd-ink-muted); font-size: 7px; } +.generator-html-pane-head span[data-tone="working"], +.detail-html-preview-head span[data-tone="working"] { color: var(--vd-warning); } +.generator-html-pane-head span[data-tone="ok"], +.detail-html-preview-head span[data-tone="ok"] { color: var(--vd-success); } +.generator-html-pane-head span[data-tone="warning"], +.detail-html-preview-head span[data-tone="warning"] { color: var(--vd-warning); } +.generator-html-pane .html-source { + min-height: 240px; + margin: 0; + width: 100%; + border: 0; + border-radius: 0; + padding: 11px; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.html-preview-frame { + display: block; + width: 100%; + min-height: 240px; + margin: 0 !important; + border: 0; + background: #fff; +} + +.listings-page .history-detail { + padding: 0; + overflow: hidden; + border-color: var(--vd-line); + border-radius: 12px; + background: var(--vd-surface-raised); +} +.listings-page #back-to-list { margin: 12px 14px 0; } +.listing-editor-shell { padding: 0 14px 16px; } +.listing-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 17px 0 14px; + border-bottom: 1px solid var(--vd-line); +} +.listing-editor-kicker { + display: block; + margin-bottom: 4px; + color: var(--vd-brand); + font-size: 7px; + font-weight: 720; + letter-spacing: .08em; + text-transform: uppercase; +} +.listing-editor-header h2 { margin: 0; color: var(--vd-ink); font-size: 18px; line-height: 1.2; text-transform: none; letter-spacing: -.02em; } +.listing-editor-header p { margin: 5px 0 0; color: var(--vd-ink-muted); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: normal; } +.listing-editor-header-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } +.listing-editor-grid { + display: grid; + grid-template-columns: minmax(220px, .78fr) minmax(420px, 1.65fr) minmax(190px, .62fr); + gap: 12px; + align-items: start; + padding-top: 13px; +} +.listing-media-editor, +.listing-content-editor, +.listing-context-panel { min-width: 0; } +.listing-media-editor, +.listing-editor-section, +.listing-context-card { + border: 1px solid var(--vd-line); + border-radius: 10px; + background: var(--vd-surface-raised); +} +.listing-media-editor { padding: 10px; position: sticky; top: 74px; } +.listing-content-editor { display: grid; gap: 10px; } +.listing-editor-section { padding: 11px; } +.listing-context-panel { display: grid; gap: 9px; position: sticky; top: 74px; } +.listing-context-card { padding: 11px; } +.listing-context-card h3, +.listing-section-heading h3 { margin: 0; color: var(--vd-ink); font-size: 9px; text-transform: none; letter-spacing: 0; } +.listing-section-heading { + min-height: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.listing-section-heading > div { display: flex; align-items: center; gap: 7px; } +.listing-section-heading > div > span { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 7px; + font-weight: 760; +} +.listing-section-heading > b { color: var(--vd-ink-muted); font-size: 7px; font-weight: 600; } +.detail-photo-stage { + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-muted); +} +.detail-photo-stage img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-empty { color: var(--vd-ink-muted); font-size: 8px; } +.detail-photo-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + margin-top: 7px; +} +.detail-photo-thumb { + position: relative; + aspect-ratio: 1; + overflow: hidden; + padding: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + cursor: pointer; +} +.detail-photo-thumb:hover, +.detail-photo-thumb.is-active { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.detail-photo-thumb.is-dragging { opacity: .45; } +.detail-photo-thumb img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-thumb span { + position: absolute; + left: 3px; + bottom: 3px; + padding: 2px 4px; + border-radius: 4px; + background: rgba(21, 20, 17, .82); + color: #fff; + font-size: 5.5px; + font-weight: 720; +} +.detail-photo-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 8px; } +.detail-photo-actions .vd-button { justify-content: center; min-width: 0; padding-inline: 6px; } +.detail-photo-help { margin: 8px 0 0 !important; color: var(--vd-ink-muted); font-size: 6.5px !important; line-height: 1.5 !important; white-space: normal !important; } +.listing-form-grid { display: grid; gap: 8px; } +.listing-form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.listing-form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.listing-form-grid label, +.listing-full-field { + min-width: 0; + display: grid; + gap: 5px; + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 680; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field { position: relative; margin-top: 9px; } +.listing-full-field > span { position: absolute; right: 1px; top: 0; color: var(--vd-ink-muted); font-size: 6.5px; font-weight: 500; } +.listing-form-grid input, +.listing-form-grid select, +.listing-full-field input, +.listing-full-field textarea { + width: 100%; + min-height: 38px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: inherit; + font-size: 8px; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field textarea { min-height: 76px; padding-block: 9px; resize: vertical; } +.listing-form-grid input:focus, +.listing-form-grid select:focus, +.listing-full-field input:focus, +.listing-full-field textarea:focus, +.detail-description-pane textarea:focus { outline: none; border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-brand-soft); } +.detail-description-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } +.detail-description-pane { min-width: 0; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.detail-description-pane #detail-description-editor { min-height: 245px; } +.detail-description-pane .ql-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-description-pane .ql-container { border: 0; min-height: 201px; font-size: 9px; } +.detail-description-pane textarea { + display: block; + width: 100%; + min-height: 245px; + padding: 10px; + border: 0; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.detail-html-preview-head { margin-top: 8px; border: 1px solid var(--vd-line); border-bottom: 0; border-radius: 8px 8px 0 0; } +.detail-html-preview { display: block; width: 100%; min-height: 280px; border: 1px solid var(--vd-line); border-radius: 0 0 8px 8px; background: #fff; } +.listing-context-card canvas { display: block; width: 118px; height: 118px; margin: 2px auto 8px; padding: 7px; border: 1px solid var(--vd-line); border-radius: 8px; background: #fff; } +.listing-context-card > strong { display: block; overflow-wrap: anywhere; color: var(--vd-ink); text-align: center; font: 8px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.listing-context-card .vd-button.full { width: 100%; justify-content: center; margin-top: 6px; } +.listing-context-card.danger-zone { border-color: color-mix(in srgb, var(--vd-danger) 35%, var(--vd-line)); } + +@media (max-width: 1280px) { + .listing-editor-grid { grid-template-columns: 230px minmax(0, 1fr); } + .listing-context-panel { grid-column: 1 / -1; position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .listing-context-card { min-height: 120px; } +} +@media (max-width: 920px) { + .generator-html-workbench, + .detail-description-split { grid-template-columns: 1fr; } + .listing-editor-grid { grid-template-columns: 1fr; } + .listing-media-editor, + .listing-context-panel { position: static; } + .listing-context-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-photo-stage { max-height: 470px; aspect-ratio: 4 / 3; } +} +@media (max-width: 620px) { + .listing-editor-header { flex-direction: column; } + .listing-editor-header-actions { width: 100%; } + .listing-editor-header-actions .vd-button { flex: 1; justify-content: center; } + .listing-form-grid.three, + .listing-form-grid.two, + .listing-context-panel { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .generator-html-workbench { padding-inline: 7px; } +} + +.history-detail .detail-photo-stage img, .history-detail .detail-photo-thumb img { object-fit: contain; object-position: center; } + +/* ========================================================================== + Slice 07: Cross-browser extensions, responsive shell & mobile photo upload + ========================================================================== */ +html, body, .main-wrapper, main, .tab-content { min-width: 0; } +img, video, canvas, iframe { max-width: 100%; } + +.mobile-upload-trigger { + width: calc(100% - 24px); + min-height: 52px; + margin: 8px 12px 0; + padding: 8px 10px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + text-align: left; + cursor: pointer; +} +.mobile-upload-trigger:hover { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.mobile-upload-trigger-icon { + width: 32px; height: 32px; display: grid; place-items: center; + border-radius: 8px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; font-weight: 800; +} +.mobile-upload-trigger span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.mobile-upload-trigger strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-trigger small { color: var(--vd-ink-muted); font-size: 7px; line-height: 1.35; } +.mobile-upload-trigger b { padding: 4px 6px; border-radius: 5px; background: var(--vd-ink); color: var(--vd-surface); font-size: 7px; } + +.html-view-toolbar { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 10px; + border-top: 1px solid var(--vd-line); + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.html-view-tabs { display: inline-flex; gap: 3px; padding: 3px; border-radius: 7px; background: var(--vd-surface-muted); } +.html-view-tab { + min-height: 28px; padding: 0 9px; border: 0; border-radius: 5px; + background: transparent; color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; cursor: pointer; +} +.html-view-tab.is-active { background: var(--vd-surface-raised); color: var(--vd-ink); box-shadow: 0 1px 5px rgba(20,18,15,.08); } +.html-switch-stage { min-width: 0; } +.html-switch-view { display: none; min-width: 0; } +.html-switch-view.is-active { display: block; } +.html-switch-view .html-source, +.detail-html-switch-stage textarea { + display: block; width: 100%; min-height: 290px; margin: 0; padding: 12px; + border: 0; border-radius: 0; resize: vertical; background: var(--vd-code-bg, #171714); color: #f3f0e7; + font: 8px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; +} +.html-switch-view .html-preview-frame { min-height: 320px; } +.detail-platform-workbench { margin-top: 9px; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.detail-html-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-html-toolbar > span { color: var(--vd-ink-muted); font-size: 7px; } +.detail-html-switch-stage .detail-html-preview { min-height: 340px; border: 0; border-radius: 0; } +.detail-wysiwyg-pane { width: 100%; } +.detail-wysiwyg-pane #detail-description-editor { min-height: 270px; } +.detail-photo-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.mobile-upload-overlay { z-index: 2500; padding: 18px; } +.mobile-upload-dialog { + width: min(760px, 100%); max-height: min(760px, calc(100dvh - 36px)); overflow: auto; + border: 1px solid var(--vd-line); border-radius: 16px; background: var(--vd-surface-raised); box-shadow: var(--vd-shadow-float); +} +.mobile-upload-dialog > header, +.mobile-upload-dialog > footer { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; } +.mobile-upload-dialog > header { border-bottom: 1px solid var(--vd-line); } +.mobile-upload-dialog > footer { justify-content: flex-end; border-top: 1px solid var(--vd-line); } +.mobile-upload-dialog-brand { display: flex; align-items: center; gap: 10px; } +.mobile-upload-dialog-brand > div { display: grid; gap: 2px; } +.mobile-upload-dialog-brand span { color: var(--vd-brand); font-size: 7px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.mobile-upload-dialog-brand h3 { margin: 0; color: var(--vd-ink); font-size: 15px; } +.mobile-upload-dialog-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: var(--vd-brand); color: #fff !important; font-size: 9px !important; } +.mobile-upload-dialog-grid { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 22px; padding: 22px; } +.mobile-upload-qr-card { display: grid; justify-items: center; align-content: center; padding: 18px; border: 1px solid var(--vd-line); border-radius: 13px; background: #fff; } +.mobile-upload-qr-card canvas { width: 240px; height: 240px; padding: 8px; background: #fff; } +.mobile-upload-session-state { min-height: 28px; display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 0 9px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; } +.mobile-upload-session-state i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.mobile-upload-session-state[data-tone="ready"] { background: var(--vd-success-soft); color: var(--vd-success); } +.mobile-upload-session-state[data-tone="warning"] { background: var(--vd-warning-soft); color: var(--vd-warning); } +.mobile-upload-session-state[data-tone="error"] { background: var(--vd-danger-soft); color: var(--vd-danger); } +.mobile-upload-dialog-copy { min-width: 0; align-self: center; } +.mobile-upload-dialog-copy h4 { margin: 4px 0 12px; color: var(--vd-ink); font-size: 21px; line-height: 1.15; letter-spacing: -.04em; } +.mobile-upload-dialog-copy ol { display: grid; gap: 9px; margin: 0 0 16px; padding-left: 22px; color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.45; } +.mobile-upload-link-wrap { display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.mobile-upload-link-wrap input { min-width: 0; height: 38px; border: 0; border-radius: 0; padding: 0 9px; background: var(--vd-surface); color: var(--vd-ink-secondary); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.mobile-upload-link-wrap button { border: 0; border-left: 1px solid var(--vd-line); padding: 0 10px; background: var(--vd-surface-muted); color: var(--vd-brand); font-size: 8px; font-weight: 720; cursor: pointer; } +.mobile-upload-dialog-copy > p { margin: 9px 0 0; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.mobile-upload-live { display: flex; justify-content: space-between; gap: 10px; margin-top: 15px; padding: 10px; border-radius: 8px; background: var(--vd-surface-muted); } +.mobile-upload-live strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-live span { color: var(--vd-ink-muted); font-size: 8px; } + +/* Fluid page widths and device adaptations */ +.main-wrapper { width: calc(100% - var(--vd-sidebar-width)); max-width: 100%; } +main { width: 100%; overflow-x: clip; } +.history-table-wrap, .publish-workspace, .storage-main, .admin-grid, .settings-form { min-width: 0; } +.history-table-wrap { overflow-x: auto; } +.history-table { min-width: 980px; } +.generator-panel, .work-panel, .listing-editor-section, .listing-media-editor, .listing-context-card { max-width: 100%; } + +@media (max-width: 1440px) { + .generator-studio-layout { grid-template-columns: minmax(240px,.72fr) minmax(300px,.95fr) minmax(330px,1.1fr); } + main { padding-inline: 18px; } +} +@media (max-width: 1180px) { + .topbar-context { min-width: 150px; } + .command-search { width: min(390px, 34vw); } + .listing-editor-grid { grid-template-columns: 230px minmax(0,1fr); } + .listing-context-panel { grid-template-columns: repeat(2,minmax(0,1fr)); } +} +@media (max-width: 1024px) { + :root { --vd-sidebar-width: 74px; } + .sidebar-header { justify-content: center; padding-inline: 10px; } + .logo-text, .nav-item span, .nav-group-label, .sidebar-footer .theme-toggle span { display: none; } + .nav-item { justify-content: center; padding: 0; } + .nav-item::before { left: -10px; } + .sidebar-footer .theme-toggle { justify-content: center; padding: 0; } + .main-wrapper { width: calc(100% - var(--vd-sidebar-width)); } + .topbar { padding-inline: 14px; } + .generator-studio-layout { grid-template-columns: 1fr 1fr; } + .generator-preview-panel { grid-column: 1 / -1; } + .studio-layout { grid-template-columns: 1fr; } + .studio-inspector { position: static; } + .publish-center-layout { grid-template-columns: minmax(0,1fr); } + .mobile-upload-dialog-grid { grid-template-columns: 250px minmax(0,1fr); gap: 16px; } +} +@media (max-width: 768px) { + :root { --vd-sidebar-width: 0px; } + body { display: block; } + .main-wrapper { width: 100%; margin-left: 0; } + .topbar { position: sticky; top: 0; z-index: 100; } + .topbar-context { flex: 0 1 auto; } + .topbar-actions { gap: 3px; } + main { padding: 13px 10px 22px; } + .page-heading { align-items: flex-start; flex-direction: column; gap: 10px; } + .generator-studio-layout, .listing-editor-grid { grid-template-columns: 1fr; } + .generator-preview-panel, .listing-media-editor, .listing-context-panel { position: static; max-height: none; } + .listing-context-panel { grid-template-columns: 1fr 1fr; } + .studio-bottom-grid { grid-template-columns: 1fr; } + .flow-board { min-width: 860px; } + .mobile-upload-dialog-grid { grid-template-columns: 1fr; padding: 15px; } + .mobile-upload-qr-card canvas { width: 210px; height: 210px; } + .mobile-upload-dialog-copy { text-align: left; } + .html-view-toolbar { align-items: flex-start; flex-direction: column; } + .generator-html-actions { width: 100%; justify-content: flex-start; overflow-x: auto; padding: 0; } +} +@media (max-width: 560px) { + .topbar { min-height: 54px; height: 54px; } + .topbar-title { max-width: 105px; } + .command-search { max-width: 42px; padding: 0; justify-content: center; } + .command-search svg { margin: 0; } + .generator-stepper { overflow-x: auto; justify-content: flex-start; } + .generator-step { min-width: max-content; flex: 0 0 auto; } + .listing-context-panel, .listing-form-grid.two, .listing-form-grid.three { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .mobile-upload-overlay { padding: 7px; align-items: flex-end; } + .mobile-upload-dialog { max-height: calc(100dvh - 14px); border-radius: 16px 16px 8px 8px; } + .mobile-upload-dialog > header, .mobile-upload-dialog > footer { padding-inline: 12px; } + .mobile-upload-dialog > footer { display: grid; grid-template-columns: 1fr 1fr; } + .mobile-upload-dialog > footer button { min-width: 0; } + .mobile-upload-live { align-items: flex-start; flex-direction: column; } + .html-view-tabs { width: 100%; } + .html-view-tab { flex: 1; } + .history-table { min-width: 850px; } +} + +/* ========================================================================== + Slice 10 — Dashboard, Studio Flow V2 & responsive hardening + ========================================================================== */ +.dashboard-page,.studio-flow-v2{min-width:0} +.dashboard-heading{align-items:center}.dashboard-controls{display:flex;align-items:center;gap:8px}.dashboard-controls select{height:34px;padding:0 34px 0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:11px} +.dashboard-layout{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:14px;align-items:start}.dashboard-main{min-width:0}.dashboard-rail{display:grid;gap:12px;min-width:0}.dashboard-card{min-width:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);box-shadow:none;overflow:hidden}.dashboard-card>header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 13px 9px}.dashboard-card>header>div{display:grid;gap:3px}.dashboard-card>header span{font-size:12px;font-weight:750;color:var(--vd-ink)}.dashboard-card>header strong{font-size:10px;font-weight:500;color:var(--vd-ink-muted)} +.dashboard-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:12px}.dashboard-kpi{position:relative;min-width:0;min-height:116px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);overflow:hidden}.dashboard-kpi-icon{width:24px;height:24px;display:grid;place-items:center;border-radius:var(--vd-radius-xs);background:var(--vd-success-soft);color:var(--vd-success);font-size:11px;font-weight:800}.dashboard-kpi>div{display:grid;gap:3px;margin-top:7px}.dashboard-kpi small{font-size:9px;color:var(--vd-ink-secondary)}.dashboard-kpi strong{font-size:17px;letter-spacing:-.035em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-kpi em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.dashboard-kpi svg{position:absolute;left:12px;right:12px;bottom:8px;width:calc(100% - 24px);height:25px;color:var(--vd-success)}.dashboard-kpi svg[data-tone="blue"]{color:var(--vd-info)}.dashboard-kpi svg[data-tone="purple"]{color:#8054b3}.dashboard-kpi svg[data-tone="orange"]{color:var(--vd-brand)} +.dashboard-chart-grid{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:12px;margin-bottom:12px}.dashboard-chart{height:250px;padding:0 8px 8px}.dashboard-chart svg{width:100%;height:100%;overflow:visible}.chart-grid line{stroke:var(--vd-line);stroke-width:1}.chart-grid text{fill:var(--vd-ink-muted);font-size:10px}.chart-area{fill:var(--vd-brand-soft)}.chart-line{fill:none;stroke:var(--vd-brand);stroke-width:2.5;vector-effect:non-scaling-stroke}.chart-point{fill:var(--vd-surface-raised);stroke:var(--vd-brand);stroke-width:2;cursor:pointer}.dashboard-bar-chart{height:250px;display:flex;align-items:end;justify-content:space-around;gap:12px;padding:16px 14px 12px}.dashboard-bar-item{height:100%;min-width:0;flex:1;display:grid;grid-template-rows:18px 1fr 18px;gap:5px;text-align:center}.dashboard-bar-item>strong{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap}.dashboard-bar-item>div{display:flex;align-items:end;justify-content:center;border-bottom:1px solid var(--vd-line)}.dashboard-bar-item i{display:block;width:min(44px,72%);min-height:5px;background:var(--vd-info);border-radius:var(--vd-radius-xs) var(--vd-radius-xs) 0 0}.dashboard-bar-item:nth-child(2) i{background:var(--vd-success)}.dashboard-bar-item:nth-child(3) i{background:var(--vd-ink)}.dashboard-bar-item:nth-child(4) i{background:var(--vd-brand)}.dashboard-bar-item span{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-category-chart{min-height:250px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:14px;align-items:center;padding:12px 14px}.dashboard-donut{width:138px;aspect-ratio:1;border-radius:50%;display:grid;place-items:center}.dashboard-donut>div{width:72%;aspect-ratio:1;border-radius:50%;display:grid;place-items:center;align-content:center;background:var(--vd-surface-raised);text-align:center}.dashboard-donut strong{font-size:13px}.dashboard-donut span{font-size:9px;color:var(--vd-ink-muted)}.dashboard-category-legend{display:grid;gap:8px}.dashboard-category-legend>div{display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:7px;align-items:center;font-size:9px}.dashboard-category-legend i{width:7px;height:7px;border-radius:50%}.dashboard-category-legend span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-category-legend strong{font-size:9px} +.dashboard-focus-card{margin-bottom:12px}.dashboard-focus-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;padding:0 12px 12px}.dashboard-focus-item{min-height:108px;padding:11px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface);color:var(--vd-ink);display:grid;grid-template-rows:auto 1fr auto;gap:6px;text-align:left;cursor:pointer}.dashboard-focus-item:hover{border-color:var(--vd-brand);transform:translateY(-1px)}.dashboard-focus-item strong{font-size:18px}.dashboard-focus-item span{font-size:10px;line-height:1.35}.dashboard-focus-item em{font-size:9px;font-style:normal;color:var(--vd-brand)}.dashboard-focus-item.tone-ready{background:color-mix(in srgb,var(--vd-success-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-failed{background:color-mix(in srgb,var(--vd-danger-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-uploads{background:color-mix(in srgb,var(--vd-info-soft) 72%,var(--vd-surface))} +.dashboard-lower-grid{display:grid;grid-template-columns:minmax(0,1fr) 255px;gap:12px}.dashboard-table-wrap{overflow:auto}.dashboard-table{width:100%;border-collapse:collapse;font-size:9px}.dashboard-table th,.dashboard-table td{padding:8px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.dashboard-table th{color:var(--vd-ink-muted);font-weight:600}.dashboard-table tbody tr{cursor:pointer}.dashboard-table tbody tr:hover{background:var(--vd-surface-muted)}.dashboard-product{display:flex;align-items:center;gap:8px;min-width:210px}.dashboard-product img,.dashboard-product>span{width:34px;height:34px;object-fit:contain;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted)}.dashboard-product>div{display:grid;gap:2px}.dashboard-product strong{font-size:9px}.dashboard-product small{font-size:8px;color:var(--vd-ink-muted)}.dashboard-status{padding:3px 6px;background:var(--vd-success-soft);color:var(--vd-success);font-size:8px}.dashboard-actions{display:grid;gap:7px;padding:0 12px 12px}.dashboard-actions button{min-height:34px;padding:0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:10px;font-weight:700;text-align:left;cursor:pointer}.dashboard-actions button:hover{border-color:var(--vd-brand);color:var(--vd-brand)}.dashboard-actions button.primary{border-color:var(--vd-brand);background:var(--vd-brand);color:#fff;text-align:center}.dashboard-quality{display:grid;grid-template-columns:58px minmax(0,1fr);gap:11px;align-items:center;padding:0 13px 12px}.dashboard-quality .confidence-ring{width:56px;height:56px}.dashboard-quality>div:last-child{display:grid;gap:3px}.dashboard-quality strong{font-size:11px;color:var(--vd-success)}.dashboard-quality p{font-size:9px;line-height:1.4;color:var(--vd-ink-muted)}.dashboard-card>.vd-button.full{width:calc(100% - 26px);margin:0 13px 13px}.dashboard-feed{display:grid;padding:0 12px 12px}.dashboard-feed button{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px;align-items:start;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.dashboard-feed button:first-child{border-top:0}.dashboard-feed-icon{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:9px;font-weight:800}.dashboard-feed-icon.tone-danger,.dashboard-feed-icon.tone-failed{background:var(--vd-danger-soft);color:var(--vd-danger)}.dashboard-feed button>span:last-child{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 6px}.dashboard-feed strong{font-size:9px;line-height:1.35}.dashboard-feed small{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.dashboard-system p{padding:0 13px 12px;font-size:9px;line-height:1.45;color:var(--vd-ink-muted)}.dashboard-empty{padding:18px!important;text-align:center;color:var(--vd-ink-muted)!important;font-size:10px!important} + +/* Studio Flow V2 */ +.today-summary-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:12px}.today-summary-grid>button{min-height:78px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);color:var(--vd-ink);display:grid;grid-template-columns:28px 42px minmax(0,1fr);grid-template-rows:auto auto;align-items:center;gap:2px 9px;text-align:left;cursor:pointer}.today-summary-grid>button:hover{border-color:var(--vd-brand)}.today-summary-grid>button>span{grid-row:1/3;width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:15px}.today-summary-grid>button:last-child>span{background:var(--vd-danger-soft);color:var(--vd-danger)}.today-summary-grid strong{grid-row:1/3;font-size:20px}.today-summary-grid small{font-size:10px;font-weight:700}.today-summary-grid em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.today-filterbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.today-filter-tabs{display:flex;align-items:center;gap:5px}.today-filter-tabs button,.today-filterbar select{height:32px;padding:0 12px;border:1px solid transparent;border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted);color:var(--vd-ink-secondary);font:inherit;font-size:10px;cursor:pointer}.today-filter-tabs button.active{border-color:var(--vd-brand);background:var(--vd-surface-raised);color:var(--vd-brand)}.today-filterbar select{border-color:var(--vd-line);background:var(--vd-surface-raised)}.studio-layout-v2{grid-template-columns:minmax(0,1fr) 300px}.today-ready-panel{margin-bottom:12px}.today-ready-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:0 12px 12px}.today-product-card{min-width:0;padding:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);overflow:hidden;background:var(--vd-surface-raised);color:var(--vd-ink);text-align:left;cursor:pointer}.today-product-card:hover,.today-product-card.selected{border-color:var(--vd-brand);box-shadow:0 0 0 1px var(--vd-brand)}.today-product-media{height:135px;position:relative;display:grid;place-items:center;background:linear-gradient(145deg,var(--vd-surface),var(--vd-surface-muted));overflow:hidden}.today-product-media img{width:100%;height:100%;object-fit:contain;padding:8px}.today-product-media>b{position:absolute;right:6px;top:6px;padding:3px 5px;background:color-mix(in srgb,var(--vd-surface-raised) 92%,transparent);font-size:8px}.today-product-copy{display:grid;gap:4px;padding:9px}.today-product-copy>strong{font-size:10px;line-height:1.35;min-height:27px}.today-product-copy>span{font-size:8px;color:var(--vd-ink-muted)}.today-product-copy>div{display:flex;align-items:center;justify-content:space-between;gap:8px}.today-product-copy em{font-size:11px;font-style:normal;font-weight:750}.today-product-copy small{font-size:8px;color:var(--vd-success)}.today-middle-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(235px,.65fr);gap:12px;margin-bottom:12px}.today-table-wrap{overflow:auto}.today-table{width:100%;border-collapse:collapse;font-size:9px}.today-table th,.today-table td{padding:7px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.today-table th{color:var(--vd-ink-muted);font-weight:600}.today-table td:first-child button{display:flex;align-items:center;gap:8px;border:0;background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-table td:first-child button>span{width:34px;height:28px;display:grid;place-items:center}.today-table td:first-child img{width:34px;height:28px;object-fit:contain}.today-table td:first-child div{display:grid;gap:2px}.today-table td:first-child strong{font-size:9px}.today-table td:first-child small{font-size:8px;color:var(--vd-ink-muted)}.today-status{padding:3px 6px;background:var(--vd-warning-soft);color:var(--vd-warning);font-size:8px}.attention-list.compact{padding:0 10px}.attention-list.compact .attention-item{width:100%;display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:8px;align-items:center;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.attention-list.compact .attention-item:first-child{border-top:0}.attention-symbol{width:18px;height:18px;display:grid;place-items:center;color:var(--vd-brand);font-size:10px}.attention-copy{display:grid;gap:2px}.attention-copy strong{font-size:9px}.attention-copy small{font-size:8px;color:var(--vd-ink-muted)}.attention-list.compact .attention-item>b{font-size:9px}.today-panel-footer{display:block;margin:8px auto 10px}.today-activity-panel{margin-bottom:0}.today-activity-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:0 12px 12px}.today-activity-grid>div{min-width:0}.today-activity-grid h4{margin-bottom:6px;font-size:9px}.today-activity-grid .activity-item{width:100%;display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-activity-grid .activity-item:first-child{border-top:0}.today-activity-grid .activity-copy{font-size:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.today-activity-grid .activity-time{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.inspector-photo-count{position:absolute;left:8px;bottom:8px;padding:4px 6px;background:rgba(0,0,0,.66);color:#fff;font-size:8px}.inspector-title-row{display:flex;align-items:start;justify-content:space-between;gap:8px}.inspector-title-row>div{min-width:0}.inspector-thumbs>span{display:grid;place-items:center;border:1px solid var(--vd-line);font-size:9px;color:var(--vd-ink-muted)} +.publish-loading-state{min-height:250px;display:grid;place-items:center;align-content:center;gap:10px;color:var(--vd-ink-muted)}.publish-loading-state>span{width:28px;height:28px;border:2px solid var(--vd-line);border-top-color:var(--vd-brand);border-radius:50%;animation:spin .8s linear infinite}.publish-loading-state strong{font-size:11px}@keyframes spin{to{transform:rotate(360deg)}} + +/* Width and device hardening */ +@media(max-width:1450px){.dashboard-kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1.35fr 1fr}.dashboard-chart-grid>.dashboard-card:last-child{grid-column:1/-1}.dashboard-category-chart{grid-template-columns:180px minmax(0,1fr)}.today-ready-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n+4){display:none}} +@media(max-width:1180px){.dashboard-layout{grid-template-columns:1fr}.dashboard-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr 1fr}.dashboard-line-card{grid-column:1/-1}.dashboard-lower-grid{grid-template-columns:1fr}.studio-layout-v2{grid-template-columns:1fr}.studio-layout-v2 .studio-inspector{position:relative;top:auto;max-height:none;display:none}.studio-layout-v2 .studio-inspector:has(.inspector-content:not(.hidden)){display:block}.today-middle-grid{grid-template-columns:1fr}.today-ready-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n){display:block}} +@media(max-width:900px){.dashboard-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr}.dashboard-chart-grid>.dashboard-card{grid-column:auto!important}.dashboard-focus-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-rail{grid-template-columns:1fr}.today-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-summary-grid>button:last-child{grid-column:1/-1}.today-ready-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-filterbar{align-items:stretch;flex-direction:column}.today-filter-tabs{overflow:auto;padding-bottom:2px}.today-filter-tabs button{flex:0 0 auto}.today-activity-grid{grid-template-columns:1fr}.dashboard-heading{align-items:flex-start}.dashboard-controls{width:100%}.dashboard-controls select{flex:1}} +@media(max-width:620px){.dashboard-kpis{grid-template-columns:1fr}.dashboard-kpi{min-height:105px}.dashboard-focus-grid{grid-template-columns:1fr}.dashboard-category-chart{grid-template-columns:1fr;justify-items:center}.dashboard-category-legend{width:100%}.dashboard-line-card .dashboard-chart{height:220px;overflow-x:auto}.dashboard-line-card svg{min-width:620px}.dashboard-lower-grid{display:block}.dashboard-actions-card{margin-top:12px}.today-summary-grid{grid-template-columns:1fr}.today-summary-grid>button:last-child{grid-column:auto}.today-ready-grid{grid-template-columns:1fr}.today-product-card{display:grid;grid-template-columns:115px minmax(0,1fr)}.today-product-media{height:108px}.today-table{min-width:650px}.today-activity-grid{padding-inline:10px}.dashboard-controls{flex-direction:column;align-items:stretch}.dashboard-controls .vd-button{width:100%}} + +/* ========================================================================== + Slice 11 — dashboard-first, fluid analytics & Studio Flow image hardening + ========================================================================== */ +.dashboard-page, +.dashboard-layout, +.dashboard-main, +.dashboard-chart-grid, +.dashboard-kpis, +.today-ready-grid, +.today-product-card { min-width: 0; } + +/* KPI cards determine their own column count from the actually available width. */ +.dashboard-kpis { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); + align-items: stretch; +} +.dashboard-kpi { + min-height: 132px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + grid-template-rows: minmax(62px, auto) 32px; + column-gap: 10px; + row-gap: 8px; + align-content: start; + padding: 12px; +} +.dashboard-kpi-icon { + grid-column: 1; + grid-row: 1; + align-self: start; + flex: none; +} +.dashboard-kpi > div { + grid-column: 2; + grid-row: 1; + min-width: 0; + margin: 0; + align-content: start; +} +.dashboard-kpi small, +.dashboard-kpi em { + overflow-wrap: anywhere; +} +.dashboard-kpi strong { + font-size: clamp(16px, 1.2vw, 20px); + line-height: 1.15; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dashboard-kpi svg { + position: static; + grid-column: 1 / -1; + grid-row: 2; + align-self: end; + width: 100%; + height: 30px; + max-width: none; + overflow: visible; +} + +.dashboard-chart-grid { + grid-template-columns: repeat(12, minmax(0, 1fr)); +} +.dashboard-line-card { grid-column: span 5; } +.dashboard-chart-grid > .dashboard-card:nth-child(2) { grid-column: span 4; } +.dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: span 3; } +.dashboard-chart, +.dashboard-bar-chart, +.dashboard-category-chart { min-width: 0; width: 100%; } +.dashboard-chart { overflow: hidden; } +.dashboard-chart svg { display: block; width: 100%; max-width: 100%; } +.dashboard-bar-chart { gap: clamp(5px, 1vw, 12px); } +.dashboard-bar-item { min-width: 42px; } +.dashboard-category-chart { grid-template-columns: minmax(112px, 42%) minmax(0, 1fr); } +.dashboard-donut { width: min(100%, 150px); } + +/* Ready products always retain the complete product photo. */ +.today-ready-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); + align-items: stretch; +} +.today-ready-grid > .today-product-card:nth-child(n) { display: grid; } +.today-product-card { + grid-template-rows: auto 1fr; + height: 100%; +} +.today-product-media { + width: 100%; + height: auto; + min-height: 150px; + aspect-ratio: 4 / 3; + padding: 9px; + overflow: hidden; + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.today-product-media img { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + padding: 0; + margin: auto; + object-fit: contain; + object-position: center; +} +.today-product-copy { align-content: start; } +.today-product-copy > strong { + min-height: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +@media (max-width: 1550px) { + .dashboard-chart-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-line-card { grid-column: 1 / -1; } + .dashboard-chart-grid > .dashboard-card:nth-child(2), + .dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: auto; } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +} + +@media (max-width: 1180px) { + .dashboard-chart-grid { grid-template-columns: 1fr; } + .dashboard-chart-grid > .dashboard-card { grid-column: auto !important; } + .dashboard-category-chart { grid-template-columns: minmax(130px, 32%) minmax(0, 1fr); } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); } +} + +@media (max-width: 760px) { + .dashboard-kpis { grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); } + .dashboard-kpi { min-height: 126px; } + .dashboard-category-chart { grid-template-columns: 1fr; justify-items: center; } + .dashboard-category-legend { width: 100%; } + .dashboard-line-card .dashboard-chart { height: 220px; overflow: hidden; } + .dashboard-line-card svg { min-width: 0 !important; width: 100% !important; } + .today-ready-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; padding-inline: 9px; } + .today-product-media { min-height: 125px; padding: 7px; } +} + +@media (max-width: 520px) { + .dashboard-kpis { grid-template-columns: 1fr; } + .dashboard-kpi { + min-height: 112px; + grid-template-columns: 26px minmax(0, 1fr) minmax(90px, 34%); + grid-template-rows: auto; + align-items: center; + } + .dashboard-kpi-icon { grid-column: 1; grid-row: 1; } + .dashboard-kpi > div { grid-column: 2; grid-row: 1; } + .dashboard-kpi svg { grid-column: 3; grid-row: 1; height: 44px; } + .today-ready-grid { grid-template-columns: 1fr; } + .today-product-card { + display: grid; + grid-template-columns: minmax(112px, 36%) minmax(0, 1fr); + grid-template-rows: 1fr; + } + .today-product-media { + height: 100%; + min-height: 125px; + aspect-ratio: auto; + } +} +.today-image-placeholder { + display: grid; + place-items: center; + width: 100%; + height: 100%; + color: var(--vd-ink-muted); + font-size: 9px; + text-align: center; +} +@media (max-width: 760px) and (min-width: 521px) { + .today-product-card { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } +} + +/* Vendoo Brand Asset Integration */ +.sidebar-logo{width:100%;display:flex;align-items:center;justify-content:flex-start;min-width:0} +.brand-logo-full{display:block;width:124px;max-width:100%;height:auto;object-fit:contain;object-position:left center} +.brand-logo-icon{display:none;width:34px;height:34px;object-fit:contain} +@media(max-width:1024px){ + .sidebar-logo{justify-content:center} + .brand-logo-full{display:none} + .brand-logo-icon{display:block} +} + +/* --- AI Model Photos --- */ +.ai-model-card { + margin-top: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + display: grid; + gap: 12px; +} + +.ai-model-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.ai-model-card-head > div:first-child { + display: flex; + gap: 12px; +} + +.ai-model-card h4 { + margin: 0 0 4px; + font-size: 15px; +} + +.ai-model-card p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.ai-model-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} + +.ai-model-controls label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.ai-model-controls .small-btn { + height: 40px; + justify-content: center; +} + +.ai-model-status { + font-size: 12px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + color: var(--text); +} + +.ai-model-status.is-working { border-color: color-mix(in srgb, var(--vd-warning) 38%, var(--line)); } +.ai-model-status.is-success { border-color: color-mix(in srgb, var(--vd-success) 42%, var(--line)); } +.ai-model-status.is-danger { border-color: color-mix(in srgb, var(--vd-danger) 42%, var(--line)); } + +.ai-model-grid { + display: grid; + gap: 12px; +} + +.ai-model-grid-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.ai-model-grid-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.ai-model-shot-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 8px; + background: var(--surface); + display: grid; + gap: 8px; +} + +.ai-model-shot-card img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 5px; + border: 1px solid var(--line); + background: #fff; +} + +.ai-model-shot-copy { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.ai-model-shot-copy strong, +.ai-model-shot-copy span { + font-size: 12px; +} + +.detail-ai-model-card { + margin-top: 14px; +} + +@media (max-width: 980px) { + .ai-model-controls { + grid-template-columns: 1fr; + } + + .ai-model-grid-head { + flex-direction: column; + align-items: stretch; + } +} + +/* Slice 15: AI provider settings */ +.settings-form { max-width: 920px; } +.ai-settings-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 16px; + background: var(--panel); +} +.ai-settings-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 14px; +} +.ai-settings-head h3 { + margin: 0 0 4px; + font-size: 16px; +} +.ai-settings-head p { + margin: 0; + color: var(--text-muted); + font-size: 12px; +} +.ai-settings-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 14px; +} +.ai-settings-grid > div { min-width: 0; } +.setting-inline-checks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin-bottom: 14px; +} +.setting-inline-checks label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.setting-inline-checks input { margin: 0; } +.inline-input-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.compact-inline { + white-space: nowrap; + height: 40px; +} +.ai-models-status { + font-size: 12px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 10px 12px; + color: var(--text); +} +.openrouter-models-preview { + margin-top: 12px; + display: grid; + gap: 10px; +} +.openrouter-model-card { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 12px; + display: grid; + gap: 8px; +} +.openrouter-model-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} +.openrouter-model-top strong { + display: block; + font-size: 13px; +} +.openrouter-model-top span { + display: block; + color: var(--text-muted); + font-size: 12px; + margin-top: 2px; +} +.openrouter-model-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.openrouter-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 5px; + font-size: 11px; + border: 1px solid var(--line); + background: var(--panel); +} +.openrouter-badge.is-free { + border-color: color-mix(in srgb, var(--success) 45%, var(--line)); +} +.openrouter-model-description { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} +@media (max-width: 960px) { + .ai-settings-grid, + .setting-inline-checks { + grid-template-columns: 1fr; + } + .inline-input-actions { + grid-template-columns: 1fr; + } +} + + +.ai-generation-history-wrap { + display: grid; + gap: 10px; +} +.ai-generation-history-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.ai-generation-history-head strong, +.ai-generation-history-head span, +.ai-generation-empty { + font-size: 12px; +} +.ai-generation-history-head span, +.ai-generation-empty { color: var(--muted); } +.ai-generation-history { + display: grid; + gap: 10px; +} +.ai-generation-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 10px; + background: var(--surface); + display: grid; + gap: 10px; +} +.ai-generation-card header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.ai-generation-card header strong, +.ai-generation-card header span { + font-size: 12px; +} +.ai-generation-mini-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.ai-generation-thumb { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + padding: 0; + overflow: hidden; + cursor: pointer; +} +.ai-generation-thumb img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} + +/* Slice 17: robust mobile navigation */ +.sidebar-close { + display: none; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink); + cursor: pointer; +} + +@media (max-width: 768px) { + body.mobile-nav-open { + overflow: hidden; + touch-action: none; + } + + .sidebar { + display: flex; + width: min(86vw, 310px) !important; + max-width: 310px; + transform: translate3d(-105%, 0, 0); + transition: transform .22s ease; + z-index: 320; + box-shadow: none; + overflow-y: auto; + visibility: visible; + pointer-events: none; + } + + .sidebar.open { + transform: translate3d(0, 0, 0); + box-shadow: 10px 0 34px rgba(23, 23, 20, .24); + pointer-events: auto; + } + + .sidebar-header { + min-height: var(--vd-topbar-height); + height: auto; + padding: 10px 12px 10px 16px; + justify-content: space-between !important; + gap: 12px; + } + + .sidebar-logo { + justify-content: flex-start !important; + min-width: 0; + } + + .brand-logo-full { + display: block !important; + max-width: 150px; + height: 34px; + object-fit: contain; + object-position: left center; + } + + .brand-logo-icon { + display: none !important; + } + + .sidebar-close { + display: inline-flex; + flex: 0 0 auto; + } + + .sidebar-nav { + padding: 12px 10px 8px; + } + + .nav-item, + .nav-item-quiet { + justify-content: flex-start !important; + min-height: 44px; + padding: 0 12px !important; + gap: 12px; + font-size: 14px; + } + + .nav-item span, + .nav-group-label, + .sidebar-footer .theme-toggle span, + .sidebar-user-info { + display: block !important; + } + + .nav-item .nav-badge { + display: inline-flex !important; + flex: 0 0 auto; + margin-left: auto; + } + + .sidebar-footer .theme-toggle { + justify-content: flex-start !important; + padding: 0 10px !important; + } + + .sidebar-user { + text-align: left; + } + + .sidebar-overlay { + position: fixed; + inset: 0; + z-index: 310; + display: block; + opacity: 0; + visibility: hidden; + pointer-events: none; + background: rgba(20, 20, 18, .46); + backdrop-filter: blur(2px); + transition: opacity .18s ease, visibility .18s ease; + } + + .sidebar-overlay.show { + display: block; + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + .sidebar-toggle { + display: inline-flex !important; + width: 38px; + height: 38px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface); + position: relative; + z-index: 2; + } +} + +/* Slice 20: Local FLUX full installer */ +.local-flux-install-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.local-flux-install-options label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text); +} +.local-flux-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.local-flux-progress { + display: grid; + gap: 8px; + margin-top: 12px; +} +.local-flux-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} +.local-flux-progress-track { + height: 10px; + border-radius: 5px; + background: var(--surface); + border: 1px solid var(--line); + overflow: hidden; +} +.local-flux-progress-track > div { + height: 100%; + width: 0; + background: var(--accent); + transition: width .25s ease; +} +@media (max-width: 980px) { + .local-flux-install-options { grid-template-columns: 1fr; } + .local-flux-actions { display: grid; grid-template-columns: 1fr 1fr; } +} +@media (max-width: 620px) { + .local-flux-actions { grid-template-columns: 1fr; } +} + +/* Slice 21: detailed FLUX installation status */ +.local-flux-status-panel { + margin-top: 12px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 12px; + display: grid; + gap: 12px; +} +.local-flux-status-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.local-flux-status-head > div { + display: grid; + gap: 3px; +} +.local-flux-status-head strong { font-size: 13px; } +.local-flux-status-head span { + font-size: 11px; + color: var(--muted); +} +.local-flux-checks { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.local-flux-check { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + padding: 9px; + display: grid; + gap: 4px; + min-width: 0; +} +.local-flux-check strong { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.local-flux-check span { + font-size: 11px; + color: var(--muted); +} +.local-flux-check.is-ok { + border-color: color-mix(in srgb, var(--vd-success) 42%, var(--line)); +} +.local-flux-check.is-missing { + border-color: color-mix(in srgb, var(--vd-warning) 40%, var(--line)); +} +.local-flux-error { + border: 1px solid color-mix(in srgb, var(--vd-danger) 45%, var(--line)); + background: color-mix(in srgb, var(--vd-danger) 7%, var(--panel)); + border-radius: 5px; + padding: 10px; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; +} +.local-flux-log-details { + border-top: 1px solid var(--line); + padding-top: 8px; +} +.local-flux-log-details summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.local-flux-log-details pre { + margin: 10px 0 0; + max-height: 260px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 5px; + padding: 10px; + background: var(--panel); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 1100px) { + .local-flux-checks { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .local-flux-checks { grid-template-columns: 1fr; } + .local-flux-status-head { flex-direction: column; } +} + +/* Slice 22: live FLUX installer telemetry */ +.local-flux-live-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.local-flux-live-metrics > div { + border: 1px solid var(--line); + border-radius: 5px; + padding: 9px 10px; + background: var(--panel); + display: grid; + gap: 3px; + min-width: 0; +} +.local-flux-live-metrics span { + color: var(--text-muted); + font-size: 11px; +} +.local-flux-live-metrics strong { + font-size: 12px; + overflow-wrap: anywhere; +} +.local-flux-state-value.is-running { color: var(--accent); } +.local-flux-state-value.is-completed { color: var(--success); } +.local-flux-state-value.is-failed, +.local-flux-state-value.is-stalled { color: var(--danger); } +.local-flux-progress-track > div.is-stalled { + animation: local-flux-pulse 1s ease-in-out infinite alternate; +} +.local-flux-progress-track > div.is-failed { + background: var(--danger); +} +@keyframes local-flux-pulse { + from { opacity: .45; } + to { opacity: 1; } +} +@media (max-width: 980px) { + .local-flux-live-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .local-flux-live-metrics { grid-template-columns: 1fr; } +} + +/* Slice 23: Settings Command Center + real AI image progress */ +.settings-workspace { + padding-bottom: 96px; +} +.settings-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 24px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 10px; + background: + radial-gradient(circle at 92% 16%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 38%), + linear-gradient(135deg, var(--panel), var(--surface)); + overflow: hidden; +} +.settings-hero-copy { max-width: 720px; } +.settings-eyebrow { + display: inline-block; + margin-bottom: 6px; + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.settings-hero h2 { margin: 0 0 8px; font-size: clamp(24px, 3vw, 38px); letter-spacing: -.04em; } +.settings-hero p { margin: 0; color: var(--text-muted); line-height: 1.55; } +.settings-hero-status { display: grid; grid-template-columns: repeat(2, minmax(150px, 1fr)); gap: 10px; min-width: min(390px, 100%); } +.settings-status-chip { + display: flex; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--panel) 90%, transparent); +} +.settings-status-chip > div { display: grid; gap: 2px; } +.settings-status-chip small { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.settings-status-chip strong { font-size: 12px; } +.settings-status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-muted) 12%, transparent); } +.settings-status-chip.is-running .settings-status-dot { background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); } +.settings-status-chip.is-stopped .settings-status-dot { background: var(--warning); box-shadow: 0 0 0 5px color-mix(in srgb, var(--warning) 14%, transparent); } +.settings-status-chip.is-disabled .settings-status-dot, +.settings-status-chip.is-not-installed .settings-status-dot { background: var(--text-muted); } + +.settings-shell { + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + gap: 18px; + align-items: start; +} +.settings-nav { + position: sticky; + top: 14px; + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.settings-nav-item { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 10px; + align-items: center; + width: 100%; + min-height: 62px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + transition: .18s ease; +} +.settings-nav-item:hover { background: var(--surface); border-color: var(--line); } +.settings-nav-item.active { background: color-mix(in srgb, var(--accent) 10%, var(--surface)); border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); } +.settings-nav-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 7px; background: var(--surface); color: var(--accent); font-size: 16px; } +.settings-nav-item.active .settings-nav-icon { background: var(--accent); color: white; } +.settings-nav-item > span:last-child { display: grid; gap: 2px; min-width: 0; } +.settings-nav-item strong { font-size: 13px; } +.settings-nav-item small { color: var(--text-muted); font-size: 10px; } + +.settings-panels { min-width: 0; } +.settings-panel { display: none; gap: 14px; } +.settings-panel.active { display: grid; } +.settings-panel-head { padding: 4px 2px 2px; } +.settings-panel-head > div { display: grid; grid-template-columns: 36px minmax(0,1fr); column-gap: 10px; align-items: center; } +.settings-panel-head span { grid-row: 1 / 3; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--accent); font-size: 11px; font-weight: 800; } +.settings-panel-head h3 { margin: 0; font-size: 20px; letter-spacing: -.02em; } +.settings-panel-head p { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; } + +.settings-card, +.flux-command-card { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + padding: 18px; + box-shadow: 0 8px 28px rgba(0,0,0,.035); +} +.settings-card-grid { display: grid; gap: 14px; } +.settings-card-grid.two-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.settings-card-grid.three-cols { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.settings-field-grid { display: grid; gap: 12px; } +.settings-field-grid.four-cols { grid-template-columns: repeat(4, minmax(0,1fr)); } +.settings-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.settings-card-head > div { display: grid; gap: 3px; } +.settings-card-head strong { font-size: 14px; } +.settings-card-head small { color: var(--text-muted); font-size: 11px; line-height: 1.4; } +.settings-card-head.split { align-items: center; } +.settings-card .setting-group { margin: 0; } +.settings-card .setting-group + .setting-group { margin-top: 12px; } +.compact-top { max-width: 520px; margin-top: 14px !important; } +.full-span { grid-column: 1 / -1; } +.checkbox-line { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font-size: 12px; } +.checkbox-line input { width: 16px; height: 16px; accent-color: var(--accent); } + +.premium-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; } +.premium-switch input { position: absolute; opacity: 0; pointer-events: none; } +.premium-switch span { position: relative; width: 42px; height: 23px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); transition: .2s ease; } +.premium-switch span::after { content: ''; position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: .2s ease; } +.premium-switch input:checked + span { background: color-mix(in srgb, var(--success) 18%, var(--surface)); border-color: color-mix(in srgb, var(--success) 55%, var(--line)); } +.premium-switch input:checked + span::after { left: 22px; background: var(--success); } +.premium-switch b { font-size: 12px; } +.premium-switch.large span { width: 54px; height: 29px; } +.premium-switch.large span::after { width: 21px; height: 21px; } +.premium-switch.large input:checked + span::after { left: 28px; } + +.reference-control-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + margin: 16px 0; + padding: 15px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 9px; + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); +} +.reference-control-copy { display: flex; align-items: flex-start; gap: 12px; } +.reference-control-copy > div { display: grid; gap: 4px; } +.reference-control-copy strong { font-size: 13px; } +.reference-control-copy small { color: var(--text-muted); font-size: 11px; line-height: 1.45; } +.reference-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 7px; background: var(--accent); color: white; } +.reference-strength-row { grid-column: 1 / -1; display: grid; grid-template-columns: auto minmax(140px, 1fr) 46px; align-items: center; gap: 10px; } +.reference-strength-row label, .reference-strength-row output { font-size: 11px; font-weight: 700; } +.reference-strength-row input[type="range"] { width: 100%; accent-color: var(--accent); } +.reference-strength-row output { text-align: right; color: var(--accent); } +.modern-checks { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; } +.modern-checks label { min-height: 44px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); font-size: 11px; } +.modern-checks input { accent-color: var(--accent); } + +.settings-disclosure { border: 1px solid var(--line); border-radius: 9px; background: var(--surface); overflow: hidden; } +.settings-disclosure summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; cursor: pointer; list-style: none; } +.settings-disclosure summary::-webkit-details-marker { display: none; } +.settings-disclosure summary span { font-size: 12px; font-weight: 700; } +.settings-disclosure summary small { color: var(--text-muted); font-size: 10px; } +.settings-disclosure[open] summary { border-bottom: 1px solid var(--line); } +.disclosure-body { padding: 16px; } + +.flux-command-card { + display: grid; + grid-template-columns: minmax(0,1fr) auto; + gap: 16px; + align-items: center; + background: + radial-gradient(circle at 8% 20%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%), + var(--panel); +} +.flux-command-main { display: flex; align-items: center; gap: 16px; min-width: 0; } +.flux-orb { display: grid; place-items: center; width: 72px; height: 72px; flex: 0 0 auto; border-radius: 22px; background: var(--surface); border: 1px solid var(--line); color: var(--text-muted); font-size: 11px; font-weight: 900; letter-spacing: .08em; box-shadow: inset 0 0 24px rgba(0,0,0,.06); } +.flux-orb.is-running { color: white; background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 58%, #6c5ce7)); border-color: transparent; box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 10%, transparent), 0 12px 30px color-mix(in srgb, var(--accent) 24%, transparent); } +.flux-orb.is-stopped { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 45%, var(--line)); } +.flux-command-copy { min-width: 0; } +.flux-command-copy > span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } +.flux-command-copy h3 { margin: 4px 0 5px; font-size: 22px; } +.flux-command-copy p { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.45; } +.flux-command-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; } +.flux-health-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; } +.flux-health-grid > div { display: grid; gap: 5px; min-height: 72px; padding: 13px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); } +.flux-health-grid span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } +.flux-health-grid strong { font-size: 13px; } +.flux-state-badge { display: inline-flex; align-items: center; min-height: 27px; padding: 0 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-size: 10px; font-weight: 800; } +.flux-state-badge.is-running { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--line)); background: color-mix(in srgb, var(--success) 8%, var(--surface)); } +.flux-state-badge.is-stopped { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 45%, var(--line)); } +.flux-state-badge.is-disabled, +.flux-state-badge.is-not-installed { color: var(--text-muted); } +.command-actions { margin-top: 14px; } + +.settings-savebar { + position: sticky; + bottom: 12px; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 18px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 92%, transparent); + backdrop-filter: blur(14px); + box-shadow: 0 12px 32px rgba(0,0,0,.12); +} +.settings-savebar > div { display: grid; gap: 2px; } +.settings-savebar strong { font-size: 12px; } +.settings-savebar small { color: var(--text-muted); font-size: 10px; } + +.ai-job-progress-card { display: grid !important; gap: 8px; padding: 12px !important; } +.ai-job-progress-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.ai-job-progress-head strong { font-size: 12px; line-height: 1.35; } +.ai-job-progress-head span { color: var(--accent); font-size: 12px; font-weight: 800; } +.ai-job-progress-track { height: 9px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); overflow: hidden; } +.ai-job-progress-track > div { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #8e77ff)); transition: width .35s ease; } +.ai-job-progress-card small { color: var(--text-muted); font-size: 10px; } + +@media (max-width: 1180px) { + .settings-shell { grid-template-columns: 1fr; } + .settings-nav { position: static; grid-template-columns: repeat(4, minmax(0,1fr)); } + .settings-nav-item { grid-template-columns: 30px minmax(0,1fr); } + .settings-field-grid.four-cols { grid-template-columns: repeat(2, minmax(0,1fr)); } + .settings-card-grid.three-cols { grid-template-columns: 1fr; } +} +@media (max-width: 820px) { + .settings-hero { align-items: flex-start; flex-direction: column; } + .settings-hero-status { width: 100%; min-width: 0; } + .settings-nav { display: flex; overflow-x: auto; } + .settings-nav-item { flex: 0 0 190px; } + .settings-card-grid.two-cols, + .settings-field-grid.four-cols, + .modern-checks, + .flux-health-grid { grid-template-columns: 1fr; } + .flux-command-card { grid-template-columns: 1fr; } + .flux-command-card > .premium-switch { justify-self: start; } +} +@media (max-width: 560px) { + .settings-hero { padding: 18px; } + .settings-hero-status { grid-template-columns: 1fr; } + .settings-card, .flux-command-card { padding: 14px; } + .reference-control-card { grid-template-columns: 1fr; } + .reference-strength-row { grid-template-columns: 1fr auto; } + .reference-strength-row input { grid-column: 1 / -1; grid-row: 2; } + .settings-savebar { align-items: stretch; flex-direction: column; } + .settings-savebar button { width: 100%; } + .flux-command-main { align-items: flex-start; } + .flux-orb { width: 58px; height: 58px; border-radius: 18px; } +} + + +body.ai-prompt-open { overflow: hidden; } +.ai-prompt-modal { + position: fixed; + inset: 0; + z-index: 10020; + display: grid; + place-items: center; + padding: 20px; +} +.ai-prompt-backdrop { + position: absolute; + inset: 0; + background: rgba(22, 22, 19, 0.56); + backdrop-filter: blur(8px); +} +.ai-prompt-shell { + position: relative; + width: min(760px, 96vw); + max-height: min(86vh, 780px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 18px; + background: var(--vd-surface-raised); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); +} +.ai-prompt-header, +.ai-prompt-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 20px; +} +.ai-prompt-header { border-bottom: 1px solid var(--vd-line); } +.ai-prompt-header h2 { margin: 0; font-size: 20px; } +.ai-prompt-header p { margin: 4px 0 0; color: var(--vd-ink-muted); font-size: 12px; } +.ai-prompt-body { padding: 20px; overflow: auto; display: grid; gap: 16px; } +.ai-prompt-summary { display: flex; flex-wrap: wrap; gap: 10px; } +.ai-prompt-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--vd-line); + border-radius: 999px; + background: var(--vd-surface); + font-size: 12px; + font-weight: 700; +} +.ai-prompt-field { display: grid; gap: 8px; } +.ai-prompt-field > span { font-weight: 700; } +.ai-prompt-field textarea { + width: 100%; + resize: vertical; + min-height: 160px; + padding: 14px; + border: 1px solid var(--vd-line); + border-radius: 12px; + background: var(--vd-surface); + color: var(--vd-ink); + font: inherit; +} +.ai-prompt-field small { color: var(--vd-ink-muted); } +.ai-prompt-check { + padding: 12px 14px; + border: 1px solid var(--vd-line); + border-radius: 12px; + background: var(--vd-surface); +} +.ai-prompt-footer-actions { display: flex; align-items: center; gap: 10px; } +@media (max-width: 760px) { + .ai-prompt-shell { width: 100%; } + .ai-prompt-header, .ai-prompt-footer { padding: 14px; } + .ai-prompt-body { padding: 14px; } + .ai-prompt-footer { flex-direction: column; align-items: stretch; } + .ai-prompt-footer-actions { width: 100%; justify-content: stretch; } + .ai-prompt-footer-actions > .vd-button, + .ai-prompt-footer > .vd-button { flex: 1 1 auto; } +} + +/* Slice 28: Eigenständiges FLUX Studio */ +.flux-studio-page { padding-bottom: 36px; } +.flux-studio-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; } +.flux-studio-engine-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex-wrap:wrap; } +.flux-studio-layout { display:grid; grid-template-columns:minmax(340px,.88fr) minmax(420px,1.12fr); gap:18px; align-items:start; } +.flux-studio-card { min-width:0; padding:20px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-sm); } +.flux-studio-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:16px; } +.flux-studio-card-head>div { display:flex; align-items:center; gap:9px; min-width:0; } +.flux-studio-card-head>div>span { display:grid; place-items:center; width:26px; height:26px; flex:0 0 auto; border-radius:8px; background:var(--vd-brand-soft); color:var(--vd-brand); font-size:10px; font-weight:850; } +.flux-studio-card-head h3 { margin:0; font-size:15px; } +.flux-studio-card-head small { color:var(--vd-ink-muted); font-size:10px; text-align:right; } +.flux-studio-prompt-field { display:grid; gap:8px; } +.flux-studio-prompt-field>span { font-size:11px; font-weight:800; } +.flux-studio-prompt-field textarea { width:100%; min-height:210px; resize:vertical; padding:14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); color:var(--vd-ink); font:inherit; line-height:1.55; } +.flux-studio-prompt-field textarea:focus { outline:2px solid color-mix(in srgb,var(--vd-brand) 28%,transparent); border-color:var(--vd-brand); } +.flux-studio-prompt-field small { justify-self:end; color:var(--vd-ink-muted); font-size:9px; } +.flux-studio-template-row { display:flex; gap:7px; flex-wrap:wrap; margin:12px 0 16px; } +.flux-studio-template-row button { min-height:30px; padding:6px 10px; border:1px solid var(--vd-line); border-radius:999px; background:var(--vd-surface); color:var(--vd-ink); font-size:9px; font-weight:750; cursor:pointer; } +.flux-studio-template-row button:hover { border-color:var(--vd-brand); color:var(--vd-brand); } +.flux-studio-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } +.flux-studio-fields label { display:grid; gap:6px; font-size:10px; font-weight:750; } +.flux-studio-fields select,.flux-studio-fields input { width:100%; min-height:38px; } +.flux-seed-input { display:grid; grid-template-columns:minmax(0,1fr) 38px; gap:6px; } +.flux-seed-input button { border:1px solid var(--vd-line); border-radius:9px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; font-size:17px; } +.flux-studio-run-row { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:18px; } +#flux-studio-status { min-width:0; color:var(--vd-ink-muted); font-size:10px; text-align:right; } +#flux-studio-status[data-tone="success"] { color:var(--vd-success); } +#flux-studio-status[data-tone="error"] { color:var(--vd-danger); } +.flux-studio-progress { display:grid; gap:7px; margin-top:14px; } +.flux-studio-progress>div:first-child { display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--vd-ink-muted); font-size:10px; } +#flux-studio-progress-bar { width:0; height:100%; background:var(--vd-brand); transition:width .25s ease; } +.flux-studio-preview-card { position:sticky; top:18px; } +.flux-studio-preview { min-height:520px; display:grid; place-items:center; overflow:hidden; border:1px solid var(--vd-line); border-radius:14px; background:linear-gradient(135deg,var(--vd-surface-muted),var(--vd-surface)); } +.flux-studio-preview img { display:block; width:100%; max-height:680px; object-fit:contain; } +.flux-studio-preview-empty { display:grid; justify-items:center; gap:7px; padding:34px; text-align:center; color:var(--vd-ink-muted); } +.flux-studio-preview-empty b { font-size:34px; color:var(--vd-brand); } +.flux-studio-preview-empty strong { color:var(--vd-ink); font-size:14px; } +.flux-studio-preview-empty span { font-size:10px; } +.flux-studio-preview-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px; } +.flux-studio-preview-actions .danger { margin-left:auto; } +.flux-studio-history-section { margin-top:22px; } +.flux-studio-history-head { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; margin-bottom:13px; } +.flux-studio-history-head h3 { margin:2px 0 0; } +.flux-studio-history { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; } +.flux-history-card { min-width:0; overflow:hidden; border:1px solid var(--vd-line); border-radius:14px; background:var(--vd-surface-raised); transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease; } +.flux-history-card:hover { transform:translateY(-2px); box-shadow:var(--vd-shadow-sm); } +.flux-history-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 12%,transparent); } +.flux-history-image { width:100%; height:190px; padding:0; border:0; background:var(--vd-surface-muted); cursor:pointer; } +.flux-history-image img { display:block; width:100%; height:100%; object-fit:cover; } +.flux-history-copy { display:grid; gap:5px; padding:11px 12px 7px; } +.flux-history-copy strong { overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; font-size:10px; line-height:1.4; } +.flux-history-copy small { color:var(--vd-ink-muted); font-size:8px; } +.flux-history-actions { display:flex; gap:6px; padding:7px 12px 12px; } +.flux-history-actions .small-btn { min-height:28px; padding:5px 8px; font-size:8px; } +.flux-history-actions .danger { color:var(--vd-danger); } +@media(max-width:1180px){.flux-studio-layout{grid-template-columns:1fr}.flux-studio-preview-card{position:static}.flux-studio-history{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:820px){.flux-studio-heading{flex-direction:column}.flux-studio-engine-actions{justify-content:flex-start}.flux-studio-history{grid-template-columns:repeat(2,minmax(0,1fr))}.flux-studio-preview{min-height:380px}} +@media(max-width:560px){.flux-studio-card{padding:14px}.flux-studio-fields{grid-template-columns:1fr}.flux-studio-run-row{align-items:stretch;flex-direction:column}.flux-studio-run-row .vd-button{width:100%}#flux-studio-status{text-align:left}.flux-studio-preview-actions{display:grid;grid-template-columns:1fr}.flux-studio-preview-actions .danger{margin-left:0}.flux-studio-history{grid-template-columns:1fr}.flux-history-image{height:240px}.flux-studio-engine-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.flux-studio-engine-actions .settings-status-chip{grid-column:1/-1}} + + +/* Slice 29: Top-Tabs, Theme Polish und optimiertes FLUX Studio */ +.settings-workspace { --settings-radius: 16px; } +.settings-hero { border-radius: var(--settings-radius); padding: 22px 24px; box-shadow: var(--vd-shadow-sm); } +.settings-shell { display: block; } +.settings-nav { + position: sticky; top: 10px; z-index: 15; + display: flex; gap: 6px; width: 100%; margin-bottom: 18px; padding: 6px; + overflow-x: auto; scrollbar-width: thin; + border: 1px solid var(--line); border-radius: 14px; + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: 0 10px 30px rgba(0,0,0,.08); backdrop-filter: blur(18px); +} +.settings-nav-item { + flex: 1 1 0; min-width: 150px; min-height: 54px; + grid-template-columns: 32px minmax(0,1fr); padding: 8px 12px; + border: 1px solid transparent; border-radius: 10px; +} +.settings-nav-item:hover { transform: translateY(-1px); } +.settings-nav-item.active { background: var(--panel); box-shadow: 0 7px 20px rgba(0,0,0,.09); } +.settings-nav-item small { display: none; } +.settings-nav-icon { width: 30px; height: 30px; border-radius: 9px; } +.settings-panel { animation: settings-panel-in .22s ease both; } +@keyframes settings-panel-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } } +.settings-panel-head { padding: 6px 2px 10px; border-bottom: 1px solid var(--line); margin-bottom: 2px; } +.settings-card, .flux-command-card, .local-flux-status-panel, .settings-disclosure { + border-radius: 14px; border-color: color-mix(in srgb, var(--line) 88%, var(--accent)); + box-shadow: 0 8px 24px rgba(0,0,0,.045); +} +.settings-card:hover { border-color: color-mix(in srgb, var(--accent) 24%, var(--line)); } +.settings-savebar { position: sticky; bottom: 12px; z-index: 20; border-radius: 14px; } +.settings-inline-note { display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding:11px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--text-muted); font-size:10px; line-height:1.45; } +.settings-inline-note strong { color:var(--accent); } + +.flux-studio-heading { padding: 22px 24px; margin-bottom: 18px; border: 1px solid var(--vd-line); border-radius: 18px; background: radial-gradient(circle at 88% 12%, color-mix(in srgb,var(--vd-brand) 18%,transparent), transparent 34%), linear-gradient(135deg,var(--vd-surface-raised),var(--vd-surface)); box-shadow:var(--vd-shadow-sm); } +.flux-studio-card { border-radius:18px; } +.flux-studio-controls { background: linear-gradient(180deg, color-mix(in srgb,var(--vd-brand) 3%,var(--vd-surface-raised)), var(--vd-surface-raised)); } +.flux-prompt-toolbar { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:12px 0 10px; } +.flux-prompt-toolbar .flux-studio-template-row { margin:0; } +.flux-optimize-button { flex:0 0 auto; min-height:32px; color:var(--vd-brand); border-color:color-mix(in srgb,var(--vd-brand) 38%,var(--vd-line)); } +.flux-prompt-quality { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:15px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); } +.flux-prompt-quality > span { display:flex; align-items:center; gap:7px; flex:0 0 auto; font-size:9px; } +.flux-prompt-quality i { width:8px; height:8px; border-radius:50%; background:var(--vd-ink-muted); box-shadow:0 0 0 4px color-mix(in srgb,var(--vd-ink-muted) 12%,transparent); } +.flux-prompt-quality p { margin:0; color:var(--vd-ink-muted); font-size:9px; text-align:right; } +.flux-prompt-quality[data-level="weak"] i { background:var(--vd-danger); } +.flux-prompt-quality[data-level="good"] i { background:#d99a24; } +.flux-prompt-quality[data-level="strong"] i { background:var(--vd-success); } +.flux-profile-strip { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin-bottom:14px; } +.flux-profile-strip button { display:grid; gap:3px; min-width:0; padding:10px; text-align:left; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; transition:.18s ease; } +.flux-profile-strip button:hover { transform:translateY(-1px); border-color:var(--vd-brand); } +.flux-profile-strip button.active { border-color:var(--vd-brand); background:var(--vd-brand-soft); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 10%,transparent); } +.flux-profile-strip strong { font-size:10px; } +.flux-profile-strip small { color:var(--vd-ink-muted); font-size:8px; line-height:1.35; } +.flux-studio-preview { position:relative; background-image: linear-gradient(45deg,color-mix(in srgb,var(--vd-line) 35%,transparent) 25%,transparent 25%),linear-gradient(-45deg,color-mix(in srgb,var(--vd-line) 35%,transparent) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,color-mix(in srgb,var(--vd-line) 35%,transparent) 75%),linear-gradient(-45deg,transparent 75%,color-mix(in srgb,var(--vd-line) 35%,transparent) 75%); background-size:20px 20px; background-position:0 0,0 10px,10px -10px,-10px 0; } +.flux-studio-preview img { position:relative; z-index:1; background:var(--vd-surface); } +.flux-studio-preview-actions { padding-top:12px; border-top:1px solid var(--vd-line); } +.flux-studio-preview-actions .primary { box-shadow:0 7px 20px color-mix(in srgb,var(--vd-brand) 22%,transparent); } +.flux-history-card { border-radius:16px; } + +@media(max-width:900px){ + .settings-nav-item { flex:0 0 165px; } + .flux-profile-strip { grid-template-columns:repeat(2,minmax(0,1fr)); } + .flux-prompt-toolbar { align-items:stretch; flex-direction:column; } + .flux-optimize-button { width:100%; } +} +@media(max-width:560px){ + .settings-nav { top:6px; padding:5px; } + .settings-nav-item { flex-basis:138px; min-width:138px; min-height:48px; padding:7px 9px; } + .settings-nav-icon { display:none; } + .settings-nav-item { grid-template-columns:1fr; } + .flux-studio-heading { padding:17px; border-radius:14px; } + .flux-profile-strip { grid-template-columns:1fr 1fr; } + .flux-prompt-quality { align-items:flex-start; flex-direction:column; } + .flux-prompt-quality p { text-align:left; } +} + +/* Slice 30 · FLUX Studio Pro */ +.flux-studio-pro-layout { grid-template-columns:minmax(390px,.9fr) minmax(440px,1.1fr); } +.flux-studio-side-stack { display:grid; gap:18px; min-width:0; } +.flux-order-options { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; margin:14px 0; } +.flux-option-block { display:grid; gap:7px; } +.flux-option-label { font-size:10px; font-weight:800; color:var(--vd-ink); } +.flux-segmented { display:grid; grid-template-columns:repeat(3,1fr); gap:5px; padding:4px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-muted); } +.flux-segmented button { min-height:34px; border:0; border-radius:8px; background:transparent; color:var(--vd-ink-muted); font-size:10px; font-weight:800; cursor:pointer; } +.flux-segmented button:hover { color:var(--vd-ink); } +.flux-segmented button.active { background:var(--vd-surface-raised); color:var(--vd-brand); box-shadow:var(--vd-shadow-sm); } +.flux-lock-toggle { display:flex; align-items:center; gap:10px; min-height:58px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); cursor:pointer; } +.flux-lock-toggle input { width:18px; height:18px; accent-color:var(--vd-brand); } +.flux-lock-toggle span { display:grid; gap:2px; } +.flux-lock-toggle strong { font-size:10px; } +.flux-lock-toggle small { color:var(--vd-ink-muted); font-size:9px; line-height:1.35; } +.flux-indeterminate-track { position:relative; height:6px; overflow:hidden; border-radius:999px; background:var(--vd-surface-muted); } +.flux-indeterminate-track i { position:absolute; inset:0 auto 0 -35%; width:35%; border-radius:999px; background:var(--vd-brand); animation:flux-indeterminate 1.25s ease-in-out infinite; } +.flux-studio-progress[data-state="done"] .flux-indeterminate-track i { left:0; width:100%; animation:none; background:var(--vd-success); } +.flux-studio-progress[data-state="error"] .flux-indeterminate-track i,.flux-studio-progress[data-state="cancelled"] .flux-indeterminate-track i { left:0; width:100%; animation:none; background:var(--vd-danger); } +@keyframes flux-indeterminate { 0%{transform:translateX(0)} 50%{transform:translateX(285%)} 100%{transform:translateX(0)} } +.flux-variant-results { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin-bottom:12px; } +.flux-variant-tile { min-width:0; padding:4px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; text-align:left; } +.flux-variant-tile.active { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 18%,transparent); } +.flux-variant-tile img { width:100%; aspect-ratio:1/1; object-fit:cover; border-radius:8px; display:block; } +.flux-variant-tile span,.flux-variant-tile small { display:block; padding:4px 4px 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.flux-variant-tile span { font-size:9px; font-weight:800; } +.flux-variant-tile small { padding-top:1px; color:var(--vd-ink-muted); font-size:8px; } +.flux-runtime-card { position:relative; } +.flux-runtime-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } +.flux-runtime-grid>div { display:grid; gap:3px; min-width:0; padding:11px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); } +.flux-runtime-grid span { color:var(--vd-ink-muted); font-size:9px; font-weight:750; } +.flux-runtime-grid strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.flux-runtime-grid small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--vd-ink-muted); font-size:8px; } +.flux-job-center { margin-top:22px; padding:20px; border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-sm); } +.flux-job-center-head { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; margin-bottom:14px; } +.flux-job-center-head h3 { margin:2px 0 3px; } +.flux-job-center-head p:last-child { margin:0; color:var(--vd-ink-muted); font-size:10px; } +.flux-job-center-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; } +.flux-job-center-actions select { min-height:38px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface); color:var(--vd-ink); padding:0 10px; } +.flux-job-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; } +.flux-job-summary>div { display:grid; gap:3px; padding:11px 12px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); } +.flux-job-summary span { color:var(--vd-ink-muted); font-size:9px; } +.flux-job-summary strong { font-size:16px; } +.flux-job-list { display:grid; gap:10px; } +.flux-job-card { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:14px; align-items:center; padding:14px; border:1px solid var(--vd-line); border-radius:14px; background:var(--vd-surface); } +.flux-job-card-main { min-width:0; } +.flux-job-status-line { display:flex; align-items:center; gap:8px; margin-bottom:7px; } +.flux-job-status-line small { color:var(--vd-ink-muted); font-size:8px; } +.flux-job-status { display:inline-flex; align-items:center; min-height:22px; padding:3px 8px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; font-weight:850; } +.flux-job-status.is-running,.flux-job-status.is-queued { background:color-mix(in srgb,var(--vd-brand) 12%,var(--vd-surface)); color:var(--vd-brand); } +.flux-job-status.is-completed { background:color-mix(in srgb,var(--vd-success) 13%,var(--vd-surface)); color:var(--vd-success); } +.flux-job-status.is-failed,.flux-job-status.is-cancelled { background:color-mix(in srgb,var(--vd-danger) 12%,var(--vd-surface)); color:var(--vd-danger); } +.flux-job-status.is-completed_with_errors,.flux-job-status.is-cancelling { background:color-mix(in srgb,#c88600 13%,var(--vd-surface)); color:#a66f00; } +.flux-job-prompt { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.flux-job-params { display:flex; gap:5px; flex-wrap:wrap; margin-top:7px; } +.flux-job-params span { padding:3px 7px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; } +.flux-job-card-main p { margin:7px 0 0; color:var(--vd-ink-muted); font-size:9px; line-height:1.45; } +.flux-job-card-main p b { color:var(--vd-danger); font-weight:700; } +.flux-job-variants { display:flex; gap:6px; flex-wrap:wrap; max-width:220px; justify-content:flex-end; } +.flux-job-thumb { position:relative; display:grid; place-items:center; width:48px; height:48px; padding:0; overflow:hidden; border:1px solid var(--vd-line); border-radius:9px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); cursor:pointer; } +.flux-job-thumb img { width:100%; height:100%; object-fit:cover; } +.flux-job-thumb>span { position:absolute; right:3px; bottom:3px; display:grid; place-items:center; min-width:16px; height:16px; border-radius:999px; background:rgba(0,0,0,.72); color:#fff; font-size:8px; } +.flux-job-thumb.is-placeholder { cursor:default; } +.flux-job-thumb.is-placeholder span { position:static; background:transparent; color:var(--vd-ink); font-weight:800; } +.flux-job-thumb.is-placeholder small { font-size:7px; } +.flux-job-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; max-width:190px; } +.flux-library-actions { display:flex; align-items:center; gap:10px; } +.flux-library-actions label { display:flex; align-items:center; gap:6px; color:var(--vd-ink-muted); font-size:9px; } +.flux-history-image { position:relative; } +.flux-history-image>span { position:absolute; top:7px; right:7px; color:#ffd15c; font-size:18px; text-shadow:0 1px 4px rgba(0,0,0,.45); } + +@media(max-width:1180px){ + .flux-studio-pro-layout{grid-template-columns:1fr} + .flux-job-card{grid-template-columns:minmax(0,1fr) auto} + .flux-job-actions{grid-column:1/-1;justify-content:flex-start;max-width:none} +} +@media(max-width:820px){ + .flux-order-options,.flux-runtime-grid{grid-template-columns:1fr} + .flux-job-center-head{align-items:stretch;flex-direction:column} + .flux-job-center-actions{justify-content:flex-start} + .flux-job-summary{grid-template-columns:repeat(2,minmax(0,1fr))} + .flux-job-card{grid-template-columns:1fr} + .flux-job-variants{justify-content:flex-start;max-width:none} + .flux-job-actions{grid-column:auto} +} +@media(max-width:560px){ + .flux-variant-results{grid-template-columns:repeat(2,minmax(0,1fr))} + .flux-job-center{padding:14px} + .flux-job-center-actions{display:grid;grid-template-columns:1fr} + .flux-job-center-actions>*{width:100%} + .flux-job-summary{grid-template-columns:1fr} + .flux-library-actions{align-items:flex-start;flex-direction:column} +} + +/* Slice 30 – sichtbares Variantenprotokoll im Job-Center */ +.flux-job-variant-ledger { display: grid; gap: .38rem; min-width: 0; } +.flux-job-variant-ledger > div { display: grid; grid-template-columns: 2.2rem minmax(6.5rem,auto) minmax(6rem,1fr) minmax(7rem,auto); align-items: center; gap: .5rem; padding: .42rem .55rem; border: 1px solid var(--border); border-radius: .65rem; background: color-mix(in srgb, var(--surface) 82%, transparent); font-size: .75rem; } +.flux-job-variant-ledger b { color: var(--text); } +.flux-job-variant-ledger span { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.flux-job-variant-ledger code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.flux-job-thumb small { display: block; max-width: 7.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 760px) { + .flux-job-variant-ledger > div { grid-template-columns: 2rem 1fr; } + .flux-job-variant-ledger code { grid-column: 1 / -1; } +} + + +/* Vendoo 1.23.2 · FLUX layout, collapsible jobs and editor tools */ +.flux-studio-left-stack { display:grid; gap:18px; min-width:0; align-content:start; } +.flux-studio-left-stack .flux-runtime-card { position:static; } +.flux-job-center-body.hidden { display:none !important; } +.flux-job-center-toggle { white-space:nowrap; } + +.flux-job-card { display:block; padding:0; overflow:hidden; } +.flux-job-card-toggle { + width:100%; display:flex; align-items:center; justify-content:space-between; gap:14px; + padding:12px 14px; border:0; color:var(--vd-ink); background:transparent; cursor:pointer; text-align:left; +} +.flux-job-card-toggle:hover { background:var(--vd-surface-muted); } +.flux-job-card-toggle-main { min-width:0; display:flex; align-items:center; gap:9px; flex:1; } +.flux-job-card-toggle-main .flux-job-prompt { min-width:0; } +.flux-job-card-toggle-meta { display:flex; align-items:center; gap:9px; flex:0 0 auto; color:var(--vd-ink-muted); font-size:8px; } +.flux-job-card-toggle-meta i { display:inline-grid; place-items:center; width:24px; height:24px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink); font-style:normal; font-size:15px; transition:transform .18s ease; } +.flux-job-card.is-expanded .flux-job-card-toggle-meta i { transform:rotate(180deg); } +.flux-job-card-body { + display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px 14px; align-items:start; + padding:0 14px 14px; border-top:1px solid var(--vd-line); +} +.flux-job-card.is-collapsed .flux-job-card-body { display:none; } +.flux-job-card-body .flux-job-card-main { padding-top:11px; } +.flux-job-card-body .flux-job-variants { padding-top:11px; } +.flux-job-card-body .flux-job-variant-ledger { grid-column:1/-1; } +.flux-job-card-body .flux-job-actions { grid-column:1/-1; max-width:none; justify-content:flex-start; } + +.pe-special-tools { gap:11px; } +.pe-tool-toggle { display:flex; align-items:flex-start; gap:9px; padding:9px; border:1px solid var(--vd-line, var(--border)); border-radius:8px; background:var(--vd-surface-muted, var(--bg-page)); cursor:pointer; } +.pe-tool-toggle input { width:17px; height:17px; margin-top:1px; accent-color:var(--vd-brand, var(--accent)); } +.pe-tool-toggle span { display:grid; gap:3px; min-width:0; } +.pe-tool-toggle strong { font-size:10px; color:var(--vd-ink, var(--text)); } +.pe-tool-toggle small { color:var(--vd-ink-muted, var(--text-muted)); font-size:8px; line-height:1.4; } +.pe-watermark-tools { display:grid; grid-template-columns:88px minmax(0,1fr); gap:8px; align-items:center; } +.pe-watermark-tools .pe-tool-toggle { grid-column:1/-1; } +.pe-watermark-tools > label:not(.pe-tool-toggle) { color:var(--vd-ink-muted, var(--text-muted)); font-size:10px; font-weight:700; } +.pe-control-body input[type="text"] { width:100%; min-height:34px; padding:7px 9px; border:1px solid var(--vd-line, var(--border)); border-radius:7px; color:var(--vd-ink, var(--text)); background:var(--vd-surface, var(--bg-input)); } + +@media(max-width:820px){ + .flux-job-card-toggle { align-items:flex-start; } + .flux-job-card-toggle-main { align-items:flex-start; flex-direction:column; } + .flux-job-card-toggle-meta { padding-top:1px; } + .flux-job-card-body { grid-template-columns:1fr; } + .flux-job-card-body .flux-job-variants,.flux-job-card-body .flux-job-variant-ledger,.flux-job-card-body .flux-job-actions { grid-column:auto; } +} + +/* ========================================================================== + Vendoo 1.24.0 · Responsive hardening, FLUX archive and media library + ========================================================================== */ + +/* Prevent any workspace child from forcing the application wider than viewport. */ +.main-wrapper, +main, +.tab-content, +.page-heading, +.history-workspace, +.history-table-shell, +.history-table, +.flux-studio-pro-layout, +.flux-studio-left-stack, +.flux-job-center, +.flux-job-center-body, +.media-library-page, +.media-toolbar, +.media-grid, +.settings-shell, +.publish-workspace, +.storage-main, +.admin-grid { min-width: 0; max-width: 100%; } + +.page-heading > *, +.flux-job-center-head > *, +.media-library-heading > * { min-width: 0; } + +/* FLUX archive, selection and paging */ +.flux-job-view-switch, +.media-view-switch { + display:inline-flex; + align-items:center; + gap:3px; + padding:3px; + border:1px solid var(--vd-line); + border-radius:10px; + background:var(--vd-surface-muted); +} +.flux-job-view-switch button, +.media-view-switch button { + min-height:30px; + padding:0 10px; + border:0; + border-radius:7px; + background:transparent; + color:var(--vd-ink-muted); + font-size:9px; + font-weight:760; + cursor:pointer; +} +.flux-job-view-switch button.active, +.media-view-switch button.active { + background:var(--vd-surface-raised); + color:var(--vd-ink); + box-shadow:0 1px 5px rgba(20,18,15,.08); +} +.flux-job-view-switch span { + display:inline-grid; + min-width:18px; + height:18px; + place-items:center; + margin-left:3px; + padding:0 5px; + border-radius:999px; + background:var(--vd-surface-muted); + font-size:8px; +} +.flux-job-bulkbar, +.media-batchbar { + min-height:46px; + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + margin-bottom:12px; + padding:8px 10px; + border:1px solid var(--vd-line); + border-radius:11px; + background:var(--vd-surface); +} +.flux-job-bulkbar > label, +.media-batchbar > label, +.media-check { + display:flex; + align-items:center; + gap:7px; + color:var(--vd-ink-secondary); + font-size:9px; + font-weight:680; +} +.flux-job-bulkbar input, +.media-batchbar input, +.media-check input, +.media-card-select input { accent-color:var(--vd-brand); } +.flux-job-bulkbar > div, +.media-batchbar > div { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } +.flux-job-pagination, +.media-pagination { + display:flex; + align-items:center; + justify-content:center; + gap:10px; + min-height:48px; + margin-top:12px; + color:var(--vd-ink-muted); + font-size:9px; +} +.flux-job-pagination .page-nav-btn, +.media-pagination .page-nav-btn { + width:34px; + height:34px; + border:1px solid var(--vd-line); + border-radius:8px; + background:var(--vd-surface-raised); + color:var(--vd-ink); + cursor:pointer; +} +.flux-job-pagination .page-nav-btn:disabled, +.media-pagination .page-nav-btn:disabled { opacity:.42; cursor:not-allowed; } +.flux-job-card-select { + display:inline-grid; + place-items:center; + flex:0 0 auto; + width:26px; + height:26px; + border-radius:7px; + background:var(--vd-surface-muted); +} +.flux-job-card-select input { width:15px; height:15px; accent-color:var(--vd-brand); } +.flux-job-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 15%,transparent); } + +/* Central media library */ +.media-library-page { padding-bottom:24px; } +.media-library-heading { align-items:center; } +.media-heading-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } +.media-summary { + display:grid; + grid-template-columns:repeat(5,minmax(0,1fr)); + gap:9px; + margin-bottom:12px; +} +.media-summary button { + min-width:0; + min-height:72px; + display:grid; + align-content:center; + gap:4px; + padding:11px 13px; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); + color:var(--vd-ink); + text-align:left; + cursor:pointer; +} +.media-summary button:hover, +.media-summary button.active { border-color:var(--vd-brand); background:var(--vd-brand-soft); } +.media-summary span { color:var(--vd-ink-muted); font-size:9px; font-weight:680; } +.media-summary strong { font-size:19px; line-height:1; } +.media-toolbar { + display:grid; + grid-template-columns:minmax(220px,1fr) repeat(2,minmax(130px,auto)) auto auto auto; + align-items:center; + gap:8px; + margin-bottom:10px; + padding:10px; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); +} +.media-toolbar select, +.media-search { + min-width:0; + min-height:38px; + border:1px solid var(--vd-line); + border-radius:9px; + background:var(--vd-surface); + color:var(--vd-ink); +} +.media-toolbar select { padding:0 30px 0 10px; font-size:9px; } +.media-search { display:grid; grid-template-columns:28px minmax(0,1fr); align-items:center; } +.media-search > span { display:grid; place-items:center; color:var(--vd-ink-muted); } +.media-search input { min-width:0; width:100%; height:36px; padding:0 9px 0 0; border:0; outline:0; background:transparent; color:var(--vd-ink); font-size:10px; } +.media-grid { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(min(220px,100%),1fr)); + gap:12px; +} +.media-card { + min-width:0; + overflow:hidden; + display:grid; + grid-template-rows:auto minmax(0,1fr) auto; + border:1px solid var(--vd-line); + border-radius:14px; + background:var(--vd-surface-raised); + transition:border-color .15s ease,transform .15s ease,box-shadow .15s ease; +} +.media-card:hover { transform:translateY(-1px); border-color:var(--vd-line-strong); box-shadow:var(--vd-shadow-sm); } +.media-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 15%,transparent); } +.media-card-image { position:relative; min-height:180px; aspect-ratio:4/3; overflow:hidden; background:var(--vd-surface-muted); } +.media-card-image img { width:100%; height:100%; display:block; object-fit:contain; } +.media-card-select { position:absolute; top:8px; left:8px; display:grid; place-items:center; width:28px; height:28px; border-radius:8px; background:rgba(18,18,16,.72); } +.media-card-select input { width:16px; height:16px; } +.media-card-favorite-mark { position:absolute; top:9px; right:10px; color:#ffd15c; font-size:18px; text-shadow:0 1px 5px rgba(0,0,0,.5); } +.media-card-preview { position:absolute; inset:auto 8px 8px auto; min-height:28px; padding:0 9px; border:1px solid rgba(255,255,255,.35); border-radius:7px; background:rgba(15,15,13,.72); color:#fff; font-size:8px; font-weight:760; cursor:pointer; } +.media-card-content { min-width:0; display:grid; align-content:start; gap:6px; padding:11px 12px; } +.media-card-content > strong { overflow:hidden; color:var(--vd-ink); font-size:10px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; } +.media-card-content > span, +.media-card-content > small { overflow:hidden; color:var(--vd-ink-muted); font-size:8px; line-height:1.4; text-overflow:ellipsis; white-space:nowrap; } +.media-card-sources { display:flex; align-items:center; gap:4px; flex-wrap:wrap; } +.media-source-chip { display:inline-flex; min-height:19px; align-items:center; padding:2px 6px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:7px; font-weight:780; } +.media-source-chip.is-flux { background:color-mix(in srgb,var(--vd-brand) 13%,var(--vd-surface)); color:var(--vd-brand); } +.media-source-chip.is-edited { background:color-mix(in srgb,var(--vd-info) 13%,var(--vd-surface)); color:var(--vd-info); } +.media-source-chip.is-ai { background:color-mix(in srgb,var(--vd-warning) 14%,var(--vd-surface)); color:var(--vd-warning); } +.media-source-chip.is-listing { background:color-mix(in srgb,var(--vd-success) 13%,var(--vd-surface)); color:var(--vd-success); } +.media-card-actions { display:flex; gap:5px; flex-wrap:wrap; padding:0 12px 12px; } +.media-card-actions .small-btn { flex:1 1 auto; min-width:0; } +.media-grid.is-list { grid-template-columns:1fr; } +.media-grid.is-list .media-card { grid-template-columns:128px minmax(0,1fr) auto; grid-template-rows:1fr; align-items:center; } +.media-grid.is-list .media-card-image { min-height:96px; height:100%; aspect-ratio:auto; } +.media-grid.is-list .media-card-actions { max-width:350px; justify-content:flex-end; padding:10px 12px 10px 0; } +.media-empty { grid-column:1/-1; min-height:260px; display:grid; place-items:center; align-content:center; gap:8px; padding:30px; border:1px dashed var(--vd-line); border-radius:14px; color:var(--vd-ink-muted); text-align:center; } +.media-empty strong { color:var(--vd-ink); font-size:14px; } +.media-empty span { font-size:9px; } + +.media-lightbox { position:fixed; inset:0; z-index:3000; display:grid; place-items:center; padding:22px; } +.media-lightbox.hidden { display:none; } +.media-lightbox-backdrop { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(10,10,9,.78); cursor:pointer; } +.media-lightbox-dialog { position:relative; width:min(1180px,100%); max-height:calc(100dvh - 44px); overflow:auto; border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-float); } +.media-lightbox-dialog > header, +.media-lightbox-dialog > footer { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:13px 16px; } +.media-lightbox-dialog > header { border-bottom:1px solid var(--vd-line); } +.media-lightbox-dialog > footer { justify-content:flex-end; flex-wrap:wrap; border-top:1px solid var(--vd-line); } +.media-lightbox-dialog h3 { margin:2px 0 0; overflow:hidden; max-width:min(700px,70vw); text-overflow:ellipsis; white-space:nowrap; } +.media-lightbox-body { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(250px,.55fr); min-height:480px; } +.media-lightbox-stage { min-width:0; display:grid; place-items:center; padding:18px; background:repeating-conic-gradient(#ddd 0 25%,#fff 0 50%) 50%/20px 20px; } +.media-lightbox-stage img { max-width:100%; max-height:70dvh; object-fit:contain; } +#media-lightbox-meta { min-width:0; overflow:auto; padding:18px; border-left:1px solid var(--vd-line); } +#media-lightbox-meta dl { display:grid; grid-template-columns:90px minmax(0,1fr); gap:8px 10px; margin:14px 0; font-size:9px; } +#media-lightbox-meta dt { color:var(--vd-ink-muted); } +#media-lightbox-meta dd { min-width:0; margin:0; overflow-wrap:anywhere; color:var(--vd-ink); } +.media-lightbox-prompt { display:grid; gap:6px; padding:10px; border-radius:10px; background:var(--vd-surface-muted); } +.media-lightbox-prompt strong { font-size:9px; } +.media-lightbox-prompt p { margin:0; color:var(--vd-ink-secondary); font-size:9px; line-height:1.5; } + +/* Background-removal sensitivity control */ +.pe-sensitivity-help { display:block; margin-top:4px; color:var(--vd-ink-muted); font-size:8px; line-height:1.4; } + +/* Real responsive listing cards instead of a viewport-breaking table. */ +@media (max-width: 1180px) { + .history-table { min-width:0; width:100%; --history-provider:0px; --history-location:0px; } + .history-table [data-column="provider"], + .history-table [data-column="location"] { display:none; } + .history-table-head, + .history-row { + grid-template-columns:34px minmax(230px,1.7fr) 96px 92px 112px minmax(128px,1fr) 40px; + } +} + +@media (max-width: 860px) { + .page-heading, + .media-library-heading { align-items:flex-start; flex-direction:column; } + .page-heading > :last-child, + .media-heading-actions { width:100%; } + .media-heading-actions .vd-button { flex:1; justify-content:center; } + + .history-filterbar { display:grid; grid-template-columns:1fr 1fr; align-items:stretch; } + .history-search-field { grid-column:1/-1; } + .history-custom-dates { grid-column:1/-1; grid-template-columns:1fr 1fr; } + .history-filterbar > .vd-button { min-height:42px; } + .history-batch-bar { flex-wrap:wrap; overflow:visible; } + .history-batch-actions { flex-wrap:wrap; } + .history-clear-selection { margin-left:0; } + .history-table-shell { overflow:visible; border:0; background:transparent; } + .history-table { min-width:0 !important; width:100%; overflow:visible; } + .history-table-head { display:none; } + .history-list { display:grid; gap:10px; } + .history-row { + min-height:0; + display:grid; + grid-template-columns:30px minmax(0,1fr) 38px; + gap:0; + align-items:start; + overflow:visible; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); + } + .history-row:last-child { border-bottom:1px solid var(--vd-line); } + .history-cell { padding:8px 10px; } + .history-cell-select { grid-column:1; grid-row:1; padding:12px 0 0 12px; } + .history-cell-article { grid-column:2; grid-row:1; padding:10px 6px; } + .history-cell-menu { grid-column:3; grid-row:1; padding:9px 8px 0 0; } + .history-cell-platform, + .history-cell-provider, + .history-cell-price, + .history-cell-status, + .history-cell-location, + .history-cell-published { + display:grid !important; + grid-template-columns:92px minmax(0,1fr); + grid-column:1/-1; + align-items:center; + gap:8px; + min-height:38px; + margin:0 10px; + padding:7px 0; + border-top:1px solid var(--vd-line); + } + .history-cell-platform::before { content:"Plattform"; } + .history-cell-provider::before { content:"AI-Provider"; } + .history-cell-price::before { content:"Preis"; } + .history-cell-status::before { content:"Status"; } + .history-cell-location::before { content:"Lagerort"; } + .history-cell-published::before { content:"Aktualisiert"; } + .history-cell-platform::before, + .history-cell-provider::before, + .history-cell-price::before, + .history-cell-status::before, + .history-cell-location::before, + .history-cell-published::before { + color:var(--vd-ink-muted); + font-size:8px; + font-weight:720; + } + .history-cell-provider { align-items:center; } + .history-cell-provider > .history-provider-mark { display:none; } + .history-cell-provider > span:last-child, + .history-cell-price > *, + .history-cell-status > *, + .history-cell-location > *, + .history-cell-published > * { grid-column:2; } + .history-cell-provider::before, + .history-cell-price::before, + .history-cell-status::before, + .history-cell-location::before, + .history-cell-published::before { grid-column:1; grid-row:1 / span 2; } + .history-status-select { max-width:170px; } + .history-publish-badges { flex-wrap:wrap; } + .history-table-footer { align-items:stretch; flex-direction:column; padding:10px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-raised); } + .history-pagination-nav { justify-content:space-between; } + + .media-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } + .media-toolbar { grid-template-columns:1fr 1fr; } + .media-search { grid-column:1/-1; } + .media-view-switch { justify-self:end; } + .media-lightbox-body { grid-template-columns:1fr; } + #media-lightbox-meta { border-top:1px solid var(--vd-line); border-left:0; } + .media-grid.is-list .media-card { grid-template-columns:110px minmax(0,1fr); } + .media-grid.is-list .media-card-actions { grid-column:1/-1; max-width:none; padding:0 10px 10px; } + + .flux-job-center-head { align-items:stretch; } + .flux-job-center-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; } + .flux-job-center-actions > * { width:100%; } + .flux-job-view-switch { grid-column:1/-1; } + .flux-job-bulkbar, + .media-batchbar { align-items:flex-start; flex-direction:column; } +} + +@media (max-width: 620px) { + main { padding-inline:10px !important; } + .history-filterbar { grid-template-columns:1fr; } + .history-search-field, + .history-custom-dates { grid-column:auto; } + .history-custom-dates { grid-template-columns:1fr; } + .history-thumb { width:48px; height:44px; } + .history-cell-platform, + .history-cell-provider, + .history-cell-price, + .history-cell-status, + .history-cell-location, + .history-cell-published { grid-template-columns:78px minmax(0,1fr); } + .history-page-buttons { max-width:100%; overflow:auto; } + + .media-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } + .media-summary button:first-child { grid-column:1/-1; } + .media-toolbar { grid-template-columns:1fr; } + .media-search { grid-column:auto; } + .media-view-switch { justify-self:start; } + .media-grid { grid-template-columns:1fr 1fr; gap:8px; } + .media-card-image { min-height:130px; } + .media-card-content { padding:9px; } + .media-card-actions { padding:0 9px 9px; } + .media-card-actions .small-btn { flex:1 1 calc(50% - 4px); } + .media-grid.is-list { grid-template-columns:1fr; } + .media-grid.is-list .media-card { grid-template-columns:92px minmax(0,1fr); } + .media-lightbox { padding:8px; } + .media-lightbox-dialog { max-height:calc(100dvh - 16px); border-radius:12px; } + .media-lightbox-dialog > header { align-items:flex-start; } + .media-lightbox-dialog > footer .vd-button { flex:1 1 calc(50% - 6px); justify-content:center; } + .media-lightbox-stage { padding:8px; } + #media-lightbox-meta { padding:12px; } + + .flux-job-center { padding:12px; } + .flux-job-center-actions { grid-template-columns:1fr; } + .flux-job-view-switch { grid-column:auto; } + .flux-job-summary { grid-template-columns:1fr 1fr; } + .flux-job-card-toggle { gap:8px; padding:10px; } + .flux-job-card-toggle-meta { flex-direction:column; align-items:flex-end; } + .flux-job-card-body { padding:0 10px 10px; } + .flux-job-bulkbar > div, + .media-batchbar > div { width:100%; } + .flux-job-bulkbar .vd-button, + .media-batchbar .vd-button { flex:1 1 calc(50% - 4px); } +} + +@media (max-width: 430px) { + .media-grid { grid-template-columns:1fr; } + .media-summary { grid-template-columns:1fr 1fr; } + .flux-job-summary { grid-template-columns:1fr; } +} + +@media (max-width: 860px) { + .history-hide-platform .history-cell-platform, + .history-hide-provider .history-cell-provider, + .history-hide-price .history-cell-price, + .history-hide-status .history-cell-status, + .history-hide-location .history-cell-location, + .history-hide-published .history-cell-published { display:none !important; } +} + +/* ========================================================================== + Vendoo 1.25.0 — responsive editor, polished gallery, prompt lab & modals + ========================================================================== */ +body.modal-open { overflow:hidden; } + +/* Listing editor: react to the available content width, not only viewport. */ +.listings-page .history-detail { + width:100%; + max-width:100%; + min-width:0; + overflow-x:clip; + container-type:inline-size; + container-name:listing-editor; +} +.listing-editor-shell, +.listing-editor-grid, +.listing-content-editor, +.listing-media-editor, +.listing-context-panel, +.listing-editor-section, +.listing-description-workbench, +.detail-platform-workbench, +.detail-description-pane, +.detail-html-switch-stage, +.detail-html-switch-stage > *, +.detail-html-preview, +#detail-description-editor, +#detail-html-source { min-width:0; max-width:100%; box-sizing:border-box; } +.listing-editor-grid { + width:100%; + grid-template-columns:minmax(190px,.78fr) minmax(0,1.65fr) minmax(170px,.62fr); +} +.listing-editor-header > div:first-child { min-width:0; } +.listing-editor-header h2, +.listing-editor-header p, +.listing-section-heading h3, +.listing-context-card strong { overflow-wrap:anywhere; word-break:break-word; } +.listing-form-grid > label, +.detail-photo-actions > *, +.listing-editor-header-actions > * { min-width:0; } +.detail-description-pane .ql-toolbar.ql-snow { display:flex; flex-wrap:wrap; gap:2px; overflow:visible; } +.detail-description-pane .ql-toolbar .ql-formats { margin-right:5px; } +.detail-platform-workbench iframe, +.detail-html-preview { width:100%; } + +@container listing-editor (max-width: 1080px) { + .listing-editor-grid { grid-template-columns:minmax(210px,260px) minmax(0,1fr); } + .listing-context-panel { grid-column:1/-1; position:static; grid-template-columns:repeat(4,minmax(0,1fr)); } + .listing-media-editor { top:64px; } +} +@container listing-editor (max-width: 760px) { + .listing-editor-shell { padding-inline:10px; } + .listing-editor-grid { grid-template-columns:minmax(0,1fr); } + .listing-media-editor,.listing-context-panel { position:static; } + .listing-context-panel { grid-template-columns:repeat(2,minmax(0,1fr)); } + .listing-form-grid.three { grid-template-columns:repeat(2,minmax(0,1fr)); } + .detail-photo-stage { aspect-ratio:4/3; max-height:460px; } +} +@container listing-editor (max-width: 500px) { + .listing-editor-header { flex-direction:column; } + .listing-editor-header-actions { width:100%; display:grid; grid-template-columns:1fr 1fr; } + .listing-form-grid.three,.listing-form-grid.two,.listing-context-panel { grid-template-columns:1fr; } + .detail-photo-actions { grid-template-columns:1fr 1fr; } + .listing-section-heading { align-items:flex-start; } +} +@media(max-width:620px) { + .listings-page .history-detail { border-radius:10px; } + .listing-editor-shell { padding-inline:8px; } + .listing-editor-header-actions { display:grid; grid-template-columns:1fr 1fr; } + .detail-photo-actions { grid-template-columns:1fr 1fr; } +} +@media(max-width:390px) { + .listing-editor-header-actions,.detail-photo-actions { grid-template-columns:1fr; } +} + +/* Gallery cards: clean overlays, aligned controls and stable action grid. */ +.media-grid { align-items:stretch; } +.media-card { isolation:isolate; } +.media-card-image { min-height:190px; } +.media-card-image::after { + content:""; position:absolute; inset:auto 0 0; height:64px; pointer-events:none; + background:linear-gradient(transparent,rgba(16,16,14,.28)); +} +.media-card-select { + top:10px; left:10px; width:32px; height:32px; padding:0; z-index:4; + border:1px solid rgba(255,255,255,.55); border-radius:10px; + background:rgba(20,20,18,.68); box-shadow:0 5px 18px rgba(0,0,0,.18); backdrop-filter:blur(8px); + cursor:pointer; +} +.media-card-select { display:grid; place-items:center; } +.media-card-select input { position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; } +.media-card-select span { + width:18px; height:18px; display:grid; place-items:center; pointer-events:none; + border:2px solid rgba(255,255,255,.94); border-radius:5px; background:rgba(255,255,255,.08); + transition:background .15s ease,border-color .15s ease,transform .15s ease; +} +.media-card-select input:focus-visible + span { outline:2px solid #fff; outline-offset:2px; } +.media-card-select input:checked + span { border-color:#fff; background:var(--vd-brand); transform:scale(1.03); } +.media-card-select input:checked + span::after { + content:""; width:8px; height:4px; margin-top:-2px; + border-left:2px solid #fff; border-bottom:2px solid #fff; transform:rotate(-45deg); +} +.media-card-favorite-toggle { + position:absolute; top:10px; right:10px; z-index:4; width:32px; height:32px; display:grid; place-items:center; + padding:0; border:1px solid rgba(255,255,255,.55); border-radius:10px; + background:rgba(20,20,18,.68); color:#fff; font-size:17px; cursor:pointer; backdrop-filter:blur(8px); +} +.media-card-favorite-toggle.is-active { color:#ffd15c; background:rgba(20,20,18,.82); } +.media-card-preview { + inset:auto 10px 10px 10px; z-index:3; min-height:34px; display:flex; align-items:center; justify-content:center; gap:6px; + border:1px solid rgba(255,255,255,.5); border-radius:10px; background:rgba(18,18,16,.78); + box-shadow:0 8px 24px rgba(0,0,0,.16); backdrop-filter:blur(9px); font-size:9px; +} +.media-card-preview span { font-size:13px; line-height:1; } +.media-card-content { min-height:116px; padding:12px 14px 10px; } +.media-card-content > strong { + display:-webkit-box; min-height:2.7em; overflow:hidden; white-space:normal; -webkit-line-clamp:2; -webkit-box-orient:vertical; + font-size:10px; line-height:1.35; +} +.media-card-content > span,.media-card-content > small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.media-card-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; padding:0 14px 14px; } +.media-card-actions .small-btn { + width:100%; min-height:34px; display:flex; align-items:center; justify-content:center; margin:0; + border-radius:9px; text-align:center; text-decoration:none; +} +.media-grid.is-list .media-card-actions { display:grid; grid-template-columns:repeat(2,minmax(120px,1fr)); width:min(360px,100%); } +@media(max-width:620px) { + .media-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } + .media-card-image { min-height:150px; } + .media-card-content { min-height:112px; } + .media-card-actions { padding-inline:10px; } +} +@media(max-width:470px) { + .media-grid { grid-template-columns:1fr; } + .media-card-image { min-height:220px; } +} + +/* Publish: calm refresh state and restored Smart Copy popup. */ +#publish-refresh[aria-busy="true"],#publish-status-refresh[aria-busy="true"] { opacity:.72; cursor:wait; transform:none !important; animation:none !important; } +.smart-copy-launcher { + min-height:360px; display:grid; grid-template-columns:56px minmax(0,1fr) auto; align-items:center; gap:18px; + padding:28px; border:1px dashed var(--vd-line-strong); border-radius:14px; background:var(--vd-surface-muted); +} +.smart-copy-launcher-icon { width:52px; height:52px; display:grid; place-items:center; border-radius:14px; background:var(--vd-brand); color:#fff; font-size:25px; } +.smart-copy-launcher h3 { margin:4px 0 7px; } +.smart-copy-launcher p { max-width:650px; color:var(--vd-ink-muted); line-height:1.55; } +.smart-copy-launcher-score { display:grid; min-width:105px; gap:2px; padding:12px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface-raised); text-align:center; } +.smart-copy-launcher-score strong { font-size:20px; color:var(--vd-brand); } +.smart-copy-launcher-score span { font-size:8px; color:var(--vd-ink-muted); } +.smart-copy-launcher > .vd-button { grid-column:2/-1; justify-self:start; } +.smart-copy-modal,.flux-prompt-lab { position:fixed; inset:0; z-index:4200; display:grid; place-items:center; padding:20px; } +.smart-copy-modal.hidden,.flux-prompt-lab.hidden { display:none; } +.smart-copy-modal-backdrop,.flux-prompt-lab-backdrop { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(9,9,8,.76); backdrop-filter:blur(4px); } +.smart-copy-modal-dialog,.flux-prompt-lab-dialog { + position:relative; width:min(1180px,100%); max-height:calc(100dvh - 40px); overflow:auto; + border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-float); +} +.smart-copy-modal-dialog > header,.flux-prompt-lab-dialog > header { + position:sticky; top:0; z-index:5; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; + padding:16px 18px; border-bottom:1px solid var(--vd-line); background:color-mix(in srgb,var(--vd-surface-raised) 94%,transparent); backdrop-filter:blur(12px); +} +.smart-copy-modal-dialog > header h2,.flux-prompt-lab-dialog > header h2 { margin:2px 0 4px; } +.smart-copy-modal-dialog > header p:last-child,.flux-prompt-lab-dialog > header p:last-child { margin:0; color:var(--vd-ink-muted); font-size:9px; } +.smart-copy-modal-content { padding:14px 18px 18px; } +.smart-copy-modal-content .publish-workspace-header { border:1px solid var(--vd-line); border-radius:12px; padding:11px; margin-bottom:12px; } +.smart-copy-modal-content .publish-smart-grid { grid-template-columns:minmax(0,1.5fr) minmax(250px,.62fr); } +@media(max-width:850px) { + .smart-copy-launcher { grid-template-columns:48px minmax(0,1fr); } + .smart-copy-launcher-score { grid-column:1/-1; width:100%; } + .smart-copy-launcher > .vd-button { grid-column:1/-1; width:100%; justify-content:center; } + .smart-copy-modal-content .publish-smart-grid { grid-template-columns:1fr; } +} +@media(max-width:560px) { + .smart-copy-modal,.flux-prompt-lab { padding:7px; } + .smart-copy-modal-dialog,.flux-prompt-lab-dialog { max-height:calc(100dvh - 14px); border-radius:12px; } + .smart-copy-modal-content { padding:9px; } + .smart-copy-launcher { min-height:0; padding:18px; grid-template-columns:1fr; text-align:center; } + .smart-copy-launcher-icon { margin:auto; } +} + +/* FLUX Prompt Lab */ +.flux-prompt-lab-dialog { width:min(920px,100%); } +.flux-prompt-lab-body { display:grid; gap:13px; padding:16px 18px; } +.flux-prompt-lab-provider { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface-muted); } +.flux-prompt-lab-provider span { color:var(--vd-ink-muted); font-size:8px; } +.flux-prompt-lab-provider strong { color:var(--vd-brand); font-size:9px; overflow-wrap:anywhere; text-align:right; } +.flux-prompt-lab-field { display:grid; gap:6px; color:var(--vd-ink-secondary); font-size:8px; font-weight:720; } +.flux-prompt-lab-field textarea { width:100%; min-height:120px; resize:vertical; padding:11px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface); color:var(--vd-ink); font:inherit; font-size:10px; line-height:1.55; } +.flux-prompt-lab-options { display:grid; grid-template-columns:minmax(220px,1fr) 1fr 1fr; gap:10px; } +.flux-prompt-lab-options > label { min-width:0; } +.flux-prompt-lab-options select { width:100%; min-height:40px; margin-top:5px; } +.flux-prompt-lab-check { display:flex; align-items:flex-start; gap:8px; padding:10px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:8px; line-height:1.45; } +.flux-prompt-lab-check input { width:17px; height:17px; flex:0 0 auto; accent-color:var(--vd-brand); } +.flux-prompt-lab-actions { display:flex; gap:9px; flex-wrap:wrap; } +.flux-prompt-lab-status { min-height:34px; display:flex; align-items:center; padding:8px 10px; border-radius:9px; background:var(--vd-brand-soft); color:var(--vd-brand); font-size:8px; } +.flux-prompt-lab-notes { min-height:18px; margin:0; color:var(--vd-ink-muted); font-size:8px; line-height:1.45; } +.flux-prompt-lab-dialog > footer { position:sticky; bottom:0; display:flex; justify-content:flex-end; gap:9px; padding:13px 18px; border-top:1px solid var(--vd-line); background:var(--vd-surface-raised); } +.flux-optimize-button { display:inline-flex; align-items:center; gap:6px; } +.flux-optimize-button > span { color:var(--vd-brand); font-size:14px; } +@media(max-width:760px) { .flux-prompt-lab-options { grid-template-columns:1fr; } } + +/* FLUX library and job pagination: compact, bounded page sizes. */ +.flux-library-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex-wrap:wrap; } +.flux-library-actions select { min-height:34px; } +.flux-history-pagination { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:13px; } +.flux-history-pagination span { color:var(--vd-ink-muted); font-size:8px; } +.flux-studio-history-section { min-width:0; } +@media(max-width:620px) { + .flux-studio-history-head { align-items:flex-start; flex-direction:column; gap:10px; } + .flux-library-actions { width:100%; justify-content:flex-start; } + .flux-library-actions > * { flex:1 1 auto; } +} + +/* Broad width hardening for all primary workspaces. */ +.publish-center-page,.media-library-page,.flux-studio-page,.settings-workspace,.generator-page,.listings-page, +.publish-center-grid,.publish-workspace-panel,.publish-workspace,.media-toolbar,.media-batchbar,.flux-job-center, +.flux-job-center-head,.flux-job-center-body,.flux-studio-layout { min-width:0; max-width:100%; box-sizing:border-box; } +img,video,canvas,iframe { max-width:100%; } +button,input,select,textarea { max-width:100%; } + +/* ===================================================================== + Vendoo 1.25.1 — fully adaptive media gallery + ===================================================================== */ +.media-library-page { + min-width:0; + container-type:inline-size; + container-name:media-library; +} + +.media-grid { + width:100%; + min-width:0; + display:grid; + grid-template-columns:repeat(auto-fit,minmax(min(100%,clamp(214px,19vw,286px)),1fr)); + gap:clamp(12px,1.35vw,20px); + align-items:start; +} + +.media-card { + container-type:inline-size; + container-name:media-card; + min-width:0; + overflow:hidden; + display:grid; + grid-template-rows:auto auto auto; + border:1px solid var(--vd-line); + border-radius:clamp(12px,5cqi,18px); + background:var(--vd-surface-raised); + box-shadow:0 2px 10px rgba(25,22,18,.055); + transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease; +} +.media-card:hover { + transform:translateY(-2px); + border-color:var(--vd-line-strong); + box-shadow:0 10px 28px rgba(25,22,18,.11); +} +.media-card.is-selected { + border-color:var(--vd-brand); + box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 17%,transparent),0 10px 28px rgba(25,22,18,.1); +} + +.media-card-image { + position:relative; + min-width:0; + min-height:0; + aspect-ratio:4/3; + overflow:hidden; + background:var(--vd-surface-muted); +} +.media-card-image::after { + content:none; +} +.media-card-open { + position:absolute; + inset:0; + z-index:1; + width:100%; + height:100%; + display:block; + margin:0; + padding:0; + overflow:hidden; + border:0; + border-radius:0; + background:transparent; + cursor:zoom-in; +} +.media-card-open img { + width:100%; + height:100%; + display:block; + object-fit:contain; + transition:transform .22s ease,filter .22s ease; +} +.media-card-open::before { + content:"⌕"; + position:absolute; + inset:50% auto auto 50%; + z-index:2; + width:clamp(34px,15cqi,48px); + height:clamp(34px,15cqi,48px); + display:grid; + place-items:center; + border:1px solid rgba(255,255,255,.68); + border-radius:999px; + background:rgba(20,20,18,.7); + color:#fff; + font-size:clamp(17px,7cqi,23px); + opacity:0; + transform:translate(-50%,-44%) scale(.92); + box-shadow:0 8px 24px rgba(0,0,0,.2); + backdrop-filter:blur(8px); + pointer-events:none; + transition:opacity .16s ease,transform .16s ease; +} +.media-card-open:hover img, +.media-card-open:focus-visible img { transform:scale(1.018); filter:brightness(.94); } +.media-card-open:hover::before, +.media-card-open:focus-visible::before { opacity:1; transform:translate(-50%,-50%) scale(1); } +.media-card-open:focus-visible { outline:3px solid var(--vd-brand); outline-offset:-3px; } +.media-card.is-selected .media-card-image { box-shadow:inset 0 0 0 3px var(--vd-brand); } + +.media-card-select, +.media-card-favorite-toggle { + position:absolute; + top:clamp(8px,4cqi,12px); + z-index:5; + width:clamp(30px,12cqi,38px); + height:clamp(30px,12cqi,38px); + display:grid; + place-items:center; + margin:0; + padding:0; + border:1px solid rgba(255,255,255,.66); + border-radius:clamp(9px,4cqi,12px); + background:rgba(22,22,20,.7); + box-shadow:0 6px 18px rgba(0,0,0,.18); + backdrop-filter:blur(9px); +} +.media-card-select { left:clamp(8px,4cqi,12px); cursor:pointer; } +.media-card-favorite-toggle { + right:clamp(8px,4cqi,12px); + color:#fff; + font-size:clamp(16px,7cqi,21px); + line-height:1; + cursor:pointer; + transition:transform .15s ease,background .15s ease,color .15s ease; +} +.media-card-favorite-toggle:hover { transform:scale(1.06); background:rgba(22,22,20,.86); } +.media-card-favorite-toggle:focus-visible { outline:2px solid #fff; outline-offset:2px; } +.media-card-favorite-toggle.is-active { color:#ffd15c; background:rgba(22,22,20,.86); } +.media-card-select input { + position:absolute; + inset:0; + width:100%; + height:100%; + margin:0; + opacity:0; + cursor:pointer; +} +.media-card-select span { + width:clamp(16px,6.5cqi,21px); + height:clamp(16px,6.5cqi,21px); + display:grid; + place-items:center; + border:2px solid rgba(255,255,255,.96); + border-radius:clamp(4px,2cqi,6px); + background:rgba(255,255,255,.08); + pointer-events:none; + transition:background .15s ease,border-color .15s ease,transform .15s ease; +} +.media-card-select input:focus-visible + span { outline:2px solid #fff; outline-offset:3px; } +.media-card-select input:checked + span { border-color:#fff; background:var(--vd-brand); transform:scale(1.04); } +.media-card-select input:checked + span::after { + content:""; + width:45%; + height:24%; + margin-top:-9%; + border-left:2px solid #fff; + border-bottom:2px solid #fff; + transform:rotate(-45deg); +} + +.media-card-content { + min-width:0; + min-height:0; + display:grid; + align-content:start; + gap:clamp(5px,2.4cqi,8px); + padding:clamp(11px,5cqi,16px) clamp(12px,5.5cqi,17px) clamp(9px,4cqi,13px); +} +.media-card-content > strong { + min-width:0; + min-height:2.7em; + display:-webkit-box; + overflow:hidden; + color:var(--vd-ink); + font-size:clamp(12px,5.2cqi,15px); + font-weight:760; + line-height:1.35; + white-space:normal; + -webkit-line-clamp:2; + -webkit-box-orient:vertical; +} +.media-card-content > span, +.media-card-content > small { + min-width:0; + overflow:hidden; + color:var(--vd-ink-muted); + font-size:clamp(10px,4cqi,12px); + line-height:1.4; + text-overflow:ellipsis; + white-space:nowrap; +} +.media-card-sources { gap:clamp(4px,2cqi,7px); } +.media-source-chip { + min-height:clamp(20px,8cqi,25px); + padding:2px clamp(7px,3cqi,10px); + font-size:clamp(9px,3.7cqi,11px); +} + +.media-card-actions { + min-width:0; + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:clamp(5px,2.2cqi,8px); + padding:0 clamp(10px,5cqi,16px) clamp(11px,5cqi,16px); +} +.media-action { + min-width:0; + min-height:clamp(40px,16cqi,48px); + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:2px; + margin:0; + padding:5px 4px; + overflow:hidden; + border:1px solid var(--vd-line); + border-radius:clamp(8px,3.7cqi,11px); + background:var(--vd-surface); + color:var(--vd-ink-secondary); + font:inherit; + text-align:center; + text-decoration:none; + cursor:pointer; + transition:border-color .15s ease,background .15s ease,color .15s ease,transform .15s ease; +} +.media-action:hover { + border-color:var(--vd-line-strong); + background:var(--vd-surface-muted); + color:var(--vd-ink); + transform:translateY(-1px); +} +.media-action:focus-visible { outline:2px solid var(--vd-brand); outline-offset:2px; } +.media-action.danger:hover { border-color:color-mix(in srgb,var(--vd-danger) 50%,var(--vd-line)); color:var(--vd-danger); } +.media-action-icon { font-size:clamp(16px,6cqi,20px); line-height:1; } +.media-action-label { + max-width:100%; + overflow:hidden; + font-size:clamp(8px,3.5cqi,10px); + font-weight:700; + line-height:1.1; + text-overflow:ellipsis; + white-space:nowrap; +} + +.media-pagination { + width:100%; + min-width:0; + min-height:48px; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin:0; + padding:8px 2px; + color:var(--vd-ink-muted); + font-size:11px; +} +.media-pagination-top { margin:4px 0 12px; border-bottom:1px solid var(--vd-line); } +.media-pagination-bottom { margin:12px 0 0; border-top:1px solid var(--vd-line); } +.media-pagination-summary { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.media-pagination-controls { display:flex; align-items:center; justify-content:flex-end; gap:8px; } +.media-page-jump { display:flex; align-items:center; gap:6px; white-space:nowrap; } +.media-page-jump select { + min-width:64px; + height:34px; + padding:0 26px 0 10px; + border:1px solid var(--vd-line); + border-radius:8px; + background:var(--vd-surface-raised); + color:var(--vd-ink); +} +.media-pagination .page-nav-btn { flex:0 0 34px; } + +.media-grid.is-list { grid-template-columns:1fr; gap:10px; } +.media-grid.is-list .media-card { + grid-template-columns:clamp(150px,22vw,230px) minmax(0,1fr) minmax(220px,auto); + grid-template-rows:auto; + align-items:center; + border-radius:14px; +} +.media-grid.is-list .media-card-image { width:100%; height:100%; min-height:118px; aspect-ratio:4/3; } +.media-grid.is-list .media-card-content { padding:14px 16px; } +.media-grid.is-list .media-card-actions { width:min(330px,100%); padding:12px 14px 12px 0; } + +@container media-card (max-width: 235px) { + .media-card-content > small { display:none; } + .media-card-actions { gap:5px; padding-inline:9px; } + .media-action { min-height:40px; padding:4px; } + .media-action-label { display:none; } + .media-action-icon { font-size:18px; } +} + +@container media-card (min-width: 340px) { + .media-action { flex-direction:row; gap:6px; } + .media-action-label { font-size:10px; } +} + +@container media-library (max-width: 760px) { + .media-toolbar { grid-template-columns:1fr 1fr; } + .media-search { grid-column:1/-1; } + .media-view-switch { justify-self:end; } + .media-batchbar { align-items:flex-start; } + .media-grid.is-list .media-card { + grid-template-columns:clamp(120px,32vw,180px) minmax(0,1fr); + } + .media-grid.is-list .media-card-actions { + grid-column:1/-1; + width:100%; + padding:0 12px 12px; + } +} + +@container media-library (max-width: 520px) { + .media-toolbar { grid-template-columns:1fr; } + .media-search { grid-column:auto; } + .media-view-switch { justify-self:stretch; } + .media-view-switch button { flex:1; } + .media-batchbar { flex-direction:column; } + .media-batchbar > div { width:100%; } + .media-batchbar .vd-button { flex:1; } + .media-pagination { align-items:flex-start; flex-direction:column; gap:7px; } + .media-pagination-controls { width:100%; justify-content:space-between; } + .media-grid.is-list .media-card { grid-template-columns:110px minmax(0,1fr); } + .media-grid.is-list .media-card-content { padding:10px 11px; } +} + +@media (hover:none) { + .media-card-open::before { display:none; } + .media-card:hover { transform:none; } +} + +/* 1.25.1 icon refinement */ +.media-card-open::before { content:none; } +.media-open-indicator { + position:absolute; + inset:50% auto auto 50%; + z-index:2; + width:clamp(36px,15cqi,48px); + height:clamp(36px,15cqi,48px); + display:grid; + place-items:center; + border:1px solid rgba(255,255,255,.7); + border-radius:999px; + background:rgba(20,20,18,.72); + color:#fff; + opacity:0; + transform:translate(-50%,-44%) scale(.92); + box-shadow:0 8px 24px rgba(0,0,0,.2); + backdrop-filter:blur(8px); + pointer-events:none; + transition:opacity .16s ease,transform .16s ease; +} +.media-open-indicator .media-action-icon { width:20px; height:20px; } +.media-card-open:hover .media-open-indicator, +.media-card-open:focus-visible .media-open-indicator { opacity:1; transform:translate(-50%,-50%) scale(1); } +.media-action-icon { + width:clamp(17px,6cqi,20px); + height:clamp(17px,6cqi,20px); + flex:0 0 auto; + fill:none; + stroke:currentColor; + stroke-width:1.8; + stroke-linecap:round; + stroke-linejoin:round; +} +@media (hover:none) { .media-open-indicator { display:none; } } + +/* Vendoo 1.26.0 — Artikel, Extensions, adaptive gallery controls and zoom preview */ +.vd-icon { display:block; flex:0 0 auto; } +.vd-icon-host { display:inline-grid; place-items:center; } +.vd-button .vd-icon { margin-right:6px; } +.icon-button .vd-icon { margin:auto; } + +/* Compact media overlays and action dock */ +.media-card-select, +.media-card-favorite-toggle { + top:clamp(6px,3cqi,9px); + width:clamp(25px,10cqi,30px); + height:clamp(25px,10cqi,30px); + min-width:0; + padding:0; + border-radius:9px; + background:rgba(20,20,18,.72); + border:1px solid rgba(255,255,255,.68); + box-shadow:0 4px 14px rgba(0,0,0,.18); + backdrop-filter:blur(7px); +} +.media-card-select { left:clamp(6px,3cqi,9px); } +.media-card-favorite-toggle { right:clamp(6px,3cqi,9px); font-size:0; } +.media-card-favorite-toggle .vd-icon { width:clamp(14px,5.5cqi,17px); height:clamp(14px,5.5cqi,17px); } +.media-card-select input + span { + width:clamp(15px,6cqi,18px); + height:clamp(15px,6cqi,18px); + border-radius:5px; + display:grid; + place-items:center; + color:transparent; +} +.media-card-select input:checked + span { color:#fff; } +.media-card-select input:checked + span::after { content:none; } +.media-card-actions { gap:5px; padding:8px; } +.media-action { + min-width:0; + min-height:34px; + padding:7px; + border-radius:9px; + justify-content:center; +} +.media-action .media-action-label { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; } +.media-action-icon { width:17px; height:17px; } +.media-open-indicator { width:38px; height:38px; } +.media-open-indicator .media-action-icon { width:18px; height:18px; } +@container media-card (min-width: 390px) { + .media-action { gap:6px; padding-inline:10px; } + .media-action .media-action-label { position:static; width:auto; height:auto; overflow:visible; clip:auto; white-space:normal; } +} + +/* Full zoomable preview */ +.media-lightbox-dialog { width:min(1440px,calc(100vw - 32px)); height:min(940px,calc(100dvh - 32px)); max-height:none; display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; overflow:hidden; } +.media-lightbox-dialog > header { min-height:58px; } +.media-lightbox-close { border-radius:999px; } +.media-lightbox-toolbar { display:flex; align-items:center; gap:8px; padding:8px 14px; border-bottom:1px solid var(--vd-line); background:var(--vd-surface-muted); } +.media-lightbox-toolbar input[type="range"] { width:min(260px,32vw); } +.media-lightbox-toolbar output { min-width:58px; color:var(--vd-ink-muted); font-size:10px; text-align:right; } +.media-lightbox-body { min-height:0; height:100%; grid-template-columns:minmax(0,1fr) minmax(245px,320px); } +.media-lightbox-stage { position:relative; display:block; min-height:0; padding:14px; overflow:auto; cursor:grab; overscroll-behavior:contain; user-select:none; } +.media-lightbox-stage.is-dragging { cursor:grabbing; } +.media-lightbox-canvas { position:relative; min-width:1px; min-height:1px; margin:auto; box-shadow:0 12px 36px rgba(0,0,0,.25); } +.media-lightbox-stage img { display:block; max-width:none; max-height:none; object-fit:fill; pointer-events:none; } +#media-lightbox-meta { min-height:0; } +.media-lightbox-dialog > footer .vd-button { display:inline-flex; align-items:center; } +@media (max-width: 820px) { + .media-lightbox-dialog { width:calc(100vw - 12px); height:calc(100dvh - 12px); } + .media-lightbox-body { grid-template-columns:1fr; grid-template-rows:minmax(300px,1fr) auto; } + #media-lightbox-meta { max-height:190px; border-left:0; border-top:1px solid var(--vd-line); } + .media-lightbox-toolbar { flex-wrap:wrap; } + .media-lightbox-toolbar input[type="range"] { flex:1; width:auto; min-width:120px; } +} + +/* Publish center: full information in the middle and resilient responsive layout */ +.smart-copy-center { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(250px,.55fr); gap:16px; padding:18px; } +.smart-copy-center-main, +.smart-copy-center-side { min-width:0; display:grid; align-content:start; gap:14px; } +.smart-copy-center-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; } +.smart-copy-center-heading h3 { margin:4px 0 0; overflow-wrap:anywhere; } +.smart-copy-center-description { max-height:280px; overflow:auto; padding:14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); line-height:1.58; overflow-wrap:anywhere; } +.smart-copy-center-tags { display:flex; flex-wrap:wrap; gap:6px; } +.smart-copy-center-tags span { padding:5px 8px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:9px; } +.smart-copy-center-meta { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.smart-copy-center-meta > div { min-width:0; padding:10px; border:1px solid var(--vd-line); border-radius:10px; } +.smart-copy-center-meta span { display:block; color:var(--vd-ink-muted); font-size:8px; } +.smart-copy-center-meta strong { display:block; margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; } +.smart-copy-center-side > p { margin:0; color:var(--vd-ink-muted); line-height:1.5; } +@media (max-width: 1050px) { + .smart-copy-center { grid-template-columns:1fr; } + .smart-copy-center-side { grid-template-columns:minmax(120px,.35fr) minmax(0,1fr); } + .smart-copy-center-side > p { grid-column:1/-1; } +} +@media (max-width: 680px) { + .smart-copy-center { padding:12px; } + .smart-copy-center-heading { align-items:stretch; flex-direction:column; } + .smart-copy-center-heading .vd-button { width:100%; justify-content:center; } + .smart-copy-center-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } + .smart-copy-center-side { grid-template-columns:1fr; } +} + +/* Extension center */ +.extensions-page { width:100%; min-width:0; } +.extensions-heading { align-items:flex-start; } +.extensions-active-browser { display:grid; gap:4px; min-width:210px; padding:12px 14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-raised); } +.extensions-active-browser span { color:var(--vd-ink-muted); font-size:8px; text-transform:uppercase; letter-spacing:.08em; } +.extensions-active-browser strong { display:flex; align-items:center; gap:8px; } +.extensions-notice { display:flex; gap:12px; align-items:flex-start; margin:0 0 16px; padding:14px 16px; border:1px solid color-mix(in srgb,var(--vd-brand) 24%,var(--vd-line)); border-radius:14px; background:color-mix(in srgb,var(--vd-brand) 6%,var(--vd-surface-raised)); } +.extensions-notice > span { width:34px; height:34px; display:grid; place-items:center; border-radius:10px; background:var(--vd-brand); color:#fff; } +.extensions-notice p { margin:4px 0 0; color:var(--vd-ink-muted); } +.extensions-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,285px),1fr)); gap:14px; } +.extension-card { min-width:0; display:grid; align-content:start; gap:13px; padding:16px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-card); } +.extension-card.is-active { border-color:color-mix(in srgb,var(--vd-brand) 55%,var(--vd-line)); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 12%,transparent),var(--vd-shadow-card); } +.extension-card header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:10px; align-items:center; } +.extension-browser-icon { width:44px; height:44px; display:grid; place-items:center; border-radius:13px; background:var(--vd-surface-muted); color:var(--vd-brand); } +.extension-card header span { color:var(--vd-brand); font-size:8px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; } +.extension-card h3 { margin:2px 0 0; } +.extension-card header b { align-self:start; padding:4px 7px; border-radius:999px; background:var(--vd-surface-muted); font-size:8px; } +.extension-card > p, +.extension-card > small { margin:0; color:var(--vd-ink-muted); line-height:1.5; } +.extension-path { min-width:0; display:grid; gap:5px; padding:10px; border-radius:10px; background:var(--vd-surface-muted); } +.extension-path span { font-size:8px; color:var(--vd-ink-muted); } +.extension-path code { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; } +.extension-card ol { margin:0; padding-left:20px; color:var(--vd-ink-secondary); line-height:1.7; } +.extension-card-actions { display:flex; gap:7px; align-items:center; } +.extension-card-actions .vd-button { flex:1; justify-content:center; } +.extension-card-actions .icon-button { flex:0 0 auto; } +.extensions-help-card { display:grid; gap:12px; margin-top:16px; padding:16px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); } +.extensions-help-card > div { display:flex; gap:12px; } +.extensions-help-card > div > span { width:36px; height:36px; display:grid; place-items:center; border-radius:11px; background:var(--vd-surface-muted); } +.extensions-help-card p { margin:4px 0 0; color:var(--vd-ink-muted); } +.extensions-help-card ol { margin:0; padding-left:22px; line-height:1.7; } +@media (max-width: 620px) { + .extensions-heading { display:grid; } + .extensions-active-browser { width:100%; min-width:0; } + .extension-card-actions { flex-wrap:wrap; } +} + +/* Fee transparency */ +.fee-platform { display:flex; align-items:center; gap:7px; } +.fee-platform span { padding:3px 6px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; } +.fee-note { margin-top:6px; max-width:700px; color:var(--vd-ink-muted); font-size:9px; line-height:1.45; } +.fee-card.is-free .fee-net-amount { color:var(--vd-success,#198754); } +.fee-card.is-free .fee-deducted { color:var(--vd-success,#198754); } + +/* Vendoo 1.26.0 — article navigation, extension center, zoom preview and fee transparency */ +.extensions-heading-actions { display:grid; gap:10px; justify-items:stretch; min-width:min(100%, 260px); } +.extensions-heading-actions .vd-button { justify-content:center; width:100%; } +.fee-heading { align-items:flex-start; margin-bottom:18px; } +.fee-heading > div { max-width:780px; } +.fee-verified-badge { display:inline-flex; align-items:center; min-height:34px; padding:0 12px; border:1px solid var(--vd-line); border-radius:999px; background:var(--vd-surface-raised); color:var(--vd-ink-muted); font-size:12px; font-weight:700; white-space:nowrap; } +@media (max-width: 720px) { + .extensions-heading-actions { width:100%; min-width:0; } + .fee-heading { display:grid; gap:12px; } + .fee-verified-badge { width:max-content; } +} + +/* Vendoo 1.26.1 — Stability, responsive and diagnostics gate */ +.connection-banner { + position: sticky; + top: var(--topbar-height, 68px); + z-index: 70; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; + padding: 10px clamp(14px, 2vw, 28px); + border-bottom: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + background: color-mix(in srgb, var(--danger) 8%, var(--surface)); + color: var(--text); + box-shadow: 0 8px 20px rgb(29 27 24 / 8%); +} +.connection-banner.hidden { display: none; } +.connection-banner-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 14%, transparent); +} +.connection-banner strong, +.connection-banner span { min-width: 0; overflow-wrap: anywhere; } +.connection-banner > span:not(.connection-banner-dot) { color: var(--text-muted); font-size: 12px; } +.connection-banner button { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.system-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; +} +.system-panel-heading h3 { margin: 2px 0 5px; font-size: clamp(20px, 2vw, 27px); } +.system-panel-heading p:last-child { max-width: 760px; margin: 0; color: var(--text-muted); line-height: 1.55; } +.system-panel-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } +.diagnostics-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + min-height: 54px; + margin-bottom: 14px; + padding: 13px 15px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-raised, var(--surface)); +} +.diagnostics-summary span { color: var(--text-muted); font-size: 12px; } +.diagnostics-summary.is-ok { border-color: color-mix(in srgb, #218a55 34%, var(--border)); background: color-mix(in srgb, #218a55 6%, var(--surface)); } +.diagnostics-summary.is-warning, +.diagnostics-summary.is-running { border-color: color-mix(in srgb, #b87911 38%, var(--border)); background: color-mix(in srgb, #b87911 7%, var(--surface)); } +.diagnostics-summary.is-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); } +.diagnostics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 10px; + margin-bottom: 22px; +} +.diagnostic-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + min-width: 0; + padding: 13px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} +.diagnostic-card-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--surface-soft, color-mix(in srgb, var(--text) 5%, transparent)); +} +.diagnostic-card strong, +.diagnostic-card p { min-width: 0; overflow-wrap: anywhere; } +.diagnostic-card p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.45; } +.diagnostic-card-status { padding: 4px 7px; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.diagnostic-card.is-ok .diagnostic-card-status { color: #176b42; background: rgb(33 138 85 / 12%); } +.diagnostic-card.is-error .diagnostic-card-status { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.diagnostic-card.is-warning .diagnostic-card-status, +.diagnostic-card.is-optional .diagnostic-card-status { color: #8b5a08; background: rgb(184 121 17 / 13%); } +.diagnostics-loading { grid-column: 1 / -1; display: flex; justify-content: center; gap: 7px; padding: 30px; } +.diagnostics-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: vd-diagnostic-pulse 900ms infinite alternate; } +.diagnostics-loading span:nth-child(2) { animation-delay: 160ms; } +.diagnostics-loading span:nth-child(3) { animation-delay: 320ms; } +@keyframes vd-diagnostic-pulse { to { opacity: .28; transform: translateY(-4px); } } + +/* Actual workspace-width contracts. These are intentionally independent from viewport width. */ +.main-wrapper, +.main-wrapper main, +.tab-content, +.tab-content > *, +.page-heading, +.form-grid, +.card, +.panel { min-width: 0; max-width: 100%; } +.tab-content img, +.tab-content video, +.tab-content canvas, +.tab-content iframe { max-width: 100%; } +.tab-content input, +.tab-content select, +.tab-content textarea, +.tab-content button { max-width: 100%; } +.tab-content pre, +.tab-content code, +.tab-content td, +.tab-content th { overflow-wrap: anywhere; word-break: break-word; } +.main-wrapper[data-layout-width="phone"] .page-heading, +.main-wrapper[data-layout-width="narrow"] .page-heading, +.tab-content[data-layout-width="phone"] .page-heading, +.tab-content[data-layout-width="narrow"] .page-heading { + align-items: stretch; + flex-direction: column; +} +.main-wrapper[data-layout-width="phone"] .page-heading > *, +.main-wrapper[data-layout-width="narrow"] .page-heading > * { min-width: 0; max-width: 100%; } +.main-wrapper[data-layout-width="phone"] .page-heading button, +.main-wrapper[data-layout-width="phone"] .page-heading select { width: 100%; } +.main-wrapper[data-layout-width="phone"] .toolbar, +.main-wrapper[data-layout-width="narrow"] .toolbar, +.main-wrapper[data-layout-width="phone"] .filter-row, +.main-wrapper[data-layout-width="narrow"] .filter-row { flex-wrap: wrap; min-width: 0; } +.main-wrapper[data-layout-width="phone"] .toolbar > *, +.main-wrapper[data-layout-width="phone"] .filter-row > * { flex: 1 1 100%; min-width: 0; } +.main-wrapper[data-layout-width="phone"] .system-panel-heading, +.main-wrapper[data-layout-width="narrow"] .system-panel-heading { flex-direction: column; } +.main-wrapper[data-layout-width="phone"] .system-panel-actions, +.main-wrapper[data-layout-width="narrow"] .system-panel-actions { width: 100%; justify-content: stretch; } +.main-wrapper[data-layout-width="phone"] .system-panel-actions > *, +.main-wrapper[data-layout-width="narrow"] .system-panel-actions > * { flex: 1 1 180px; } + +@media (max-width: 640px) { + .connection-banner { grid-template-columns: auto minmax(0, 1fr); } + .connection-banner > span:not(.connection-banner-dot) { grid-column: 2; } + .connection-banner button { grid-column: 1 / -1; width: 100%; } + .diagnostic-card { grid-template-columns: auto minmax(0, 1fr); } + .diagnostic-card-status { grid-column: 2; justify-self: start; } +} + +@media (prefers-reduced-motion: reduce) { + .diagnostics-loading span { animation: none; } +} + +/* Vendoo 1.27.0 — Batch Image Factory */ +.image-factory-page { container-type: inline-size; } +.image-factory-heading-actions, +.image-factory-job-tools, +.image-factory-source-tools, +.image-factory-heading-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.image-factory-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(250px, 320px); gap: 18px; align-items: start; } +.surface-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +.image-factory-config, +.image-factory-status, +.image-factory-jobs { padding: clamp(16px, 2vw, 24px); min-width: 0; } +.section-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; } +.section-title-row h3 { margin: 2px 0 0; } +.image-factory-selection { padding: 7px 10px; border-radius: 999px; background: var(--accent-light); color: var(--accent); font-size: 12px; font-weight: 700; white-space: nowrap; } +.image-factory-source-head, +.image-factory-profile-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; margin: 24px 0 10px; } +.image-factory-source-head > div:first-child, +.image-factory-profile-head > div:first-child { display: grid; gap: 3px; } +.image-factory-source-head span, +.image-factory-profile-head span { color: var(--text-muted); font-size: 12px; } +.image-factory-source-tools input, +.image-factory-source-tools select { min-width: 150px; } +.image-factory-source-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); gap: 10px; max-height: 390px; overflow: auto; padding: 2px; } +.image-factory-source-card { position: relative; display: grid; grid-template-rows: minmax(90px, 1fr) auto; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; } +.image-factory-source-card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); transform: translateY(-1px); } +.image-factory-source-card.is-selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); } +.image-factory-source-card > input { position: absolute; opacity: 0; pointer-events: none; } +.image-factory-source-card > img { width: 100%; height: 100%; min-height: 100px; aspect-ratio: 1 / 1; object-fit: cover; background: var(--bg-card); } +.image-factory-source-check { position: absolute; top: 7px; left: 7px; width: 23px; height: 23px; border-radius: 7px; display: grid; place-items: center; color: transparent; background: color-mix(in srgb, var(--bg-card) 92%, transparent); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); box-shadow: 0 2px 8px rgba(0,0,0,.14); } +.image-factory-source-card.is-selected .image-factory-source-check { background: var(--accent); border-color: var(--accent); color: white; } +.image-factory-source-caption { display: grid; gap: 2px; padding: 8px 9px; min-width: 0; } +.image-factory-source-caption strong, +.image-factory-source-caption small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-source-caption strong { font-size: 11px; } +.image-factory-source-caption small { font-size: 10px; color: var(--text-muted); } +.image-factory-profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; } +.image-factory-profile-card { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; min-width: 0; padding: 13px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); } +.image-factory-profile-card.is-selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent); } +.image-factory-profile-select { position: absolute; inset: 0; cursor: pointer; z-index: 1; } +.image-factory-profile-select input { position: absolute; opacity: 0; } +.image-factory-profile-select span { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 7px; color: transparent; border: 1px solid var(--border); background: var(--bg-card); } +.image-factory-profile-card.is-selected .image-factory-profile-select span { color: white; border-color: var(--accent); background: var(--accent); } +.image-factory-profile-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: var(--accent-light); color: var(--accent); font-weight: 800; } +.image-factory-profile-copy { min-width: 0; padding-right: 24px; } +.image-factory-profile-copy strong { font-size: 13px; } +.image-factory-profile-copy p { margin: 4px 0 7px; color: var(--text-secondary); font-size: 11px; line-height: 1.35; } +.image-factory-profile-copy small { color: var(--text-muted); font-size: 10px; } +.image-factory-profile-badge { align-self: end; padding: 3px 7px; border-radius: 999px; background: var(--bg-card); color: var(--text-muted); font-size: 9px; text-transform: uppercase; font-weight: 700; } +.image-factory-profile-actions { position: relative; z-index: 2; display: flex; align-items: flex-end; gap: 4px; } +.image-factory-profile-actions button { border: 0; background: transparent; color: var(--text-muted); font-size: 10px; cursor: pointer; padding: 4px; } +.image-factory-profile-actions button:hover { color: var(--accent); } +.image-factory-options { margin-top: 20px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.image-factory-options summary { cursor: pointer; padding: 13px 15px; font-weight: 700; background: var(--bg-page); } +.image-factory-options-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding: 16px; } +.image-factory-options .toggle-row { align-self: center; } +.image-factory-submit { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--border); } +.image-factory-submit > div { display: grid; gap: 3px; min-width: 0; } +.image-factory-submit span { color: var(--text-muted); font-size: 11px; } +.image-factory-status { position: sticky; top: 18px; } +.image-factory-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; margin-top: 16px; } +.image-factory-stat-grid > div { display: grid; gap: 3px; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-page); } +.image-factory-stat-grid span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.image-factory-stat-grid strong { font-size: 21px; } +.image-factory-help { margin-top: 16px; padding: 13px; border-radius: 11px; background: var(--accent-light); } +.image-factory-help p { margin: 5px 0 0; color: var(--text-secondary); font-size: 11px; line-height: 1.45; } +.image-factory-jobs { margin-top: 18px; } +.image-factory-job-list { display: grid; gap: 10px; } +.image-factory-job { overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); } +.image-factory-job-head { width: 100%; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 13px 14px 10px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; } +.image-factory-job-chevron { font-size: 22px; color: var(--text-muted); transition: transform .18s ease; } +.image-factory-job.is-expanded .image-factory-job-chevron { transform: rotate(90deg); } +.image-factory-job-title { display: grid; min-width: 0; } +.image-factory-job-title strong, +.image-factory-job-title small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-job-title small { color: var(--text-muted); font-size: 10px; margin-top: 3px; } +.image-factory-job-count { font-weight: 700; font-variant-numeric: tabular-nums; } +.image-factory-job-status { padding: 5px 8px; border-radius: 999px; font-size: 10px; font-weight: 800; background: var(--bg-card); } +.image-factory-job.is-running .image-factory-job-status { background: #e8f2ff; color: #175fae; } +.image-factory-job.is-completed .image-factory-job-status { background: #e7f6ed; color: #257046; } +.image-factory-job.is-failed .image-factory-job-status { background: #ffeded; color: #a93838; } +.image-factory-job.is-partial .image-factory-job-status, +.image-factory-job.is-paused .image-factory-job-status { background: #fff5dc; color: #8d6411; } +.image-factory-job-progress { height: 4px; margin: 0 14px; overflow: hidden; border-radius: 99px; background: var(--border); } +.image-factory-job-progress span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .2s ease; } +.image-factory-job-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 6px; padding: 10px 14px 13px; } +.image-factory-job-detail { border-top: 1px solid var(--border); padding: 12px 14px 14px; } +.image-factory-job-loading { color: var(--text-muted); font-size: 12px; } +.image-factory-job-items { display: grid; gap: 7px; } +.image-factory-job-item { display: grid; grid-template-columns: 46px minmax(0, 1fr) auto auto; gap: 9px; align-items: center; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); } +.image-factory-job-item img { width: 46px; height: 46px; object-fit: cover; border-radius: 7px; background: var(--bg-page); } +.image-factory-job-item > div { display: grid; min-width: 0; } +.image-factory-job-item strong, +.image-factory-job-item span, +.image-factory-job-item small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-job-item span, +.image-factory-job-item small { color: var(--text-muted); font-size: 10px; } +.image-factory-job-item small { color: var(--danger); } +.image-factory-item-status { padding: 4px 7px; border-radius: 999px; background: var(--bg-page); font-size: 10px; font-weight: 700; } +.image-factory-pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 16px; } +.image-factory-profile-modal { max-width: 620px; } +.image-factory-profile-form { display: grid; gap: 13px; } + +@container (max-width: 900px) { + .image-factory-layout { grid-template-columns: 1fr; } + .image-factory-status { position: static; } + .image-factory-stat-grid { grid-template-columns: repeat(3, 1fr); } +} +@container (max-width: 680px) { + .image-factory-source-head, + .image-factory-profile-head, + .image-factory-submit, + .section-title-row { align-items: stretch; flex-direction: column; } + .image-factory-source-tools > * { flex: 1 1 160px; min-width: 0; } + .image-factory-options-grid { grid-template-columns: 1fr; } + .image-factory-submit .primary-btn { width: 100%; } + .image-factory-job-head { grid-template-columns: 18px minmax(0, 1fr) auto; } + .image-factory-job-status { grid-column: 2 / -1; justify-self: start; } + .image-factory-job-item { grid-template-columns: 42px minmax(0, 1fr) auto; } + .image-factory-job-item img { width: 42px; height: 42px; } + .image-factory-job-item .vd-button { grid-column: 2 / -1; width: 100%; } +} +@container (max-width: 430px) { + .image-factory-source-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .image-factory-profile-grid { grid-template-columns: 1fr; } + .image-factory-stat-grid { grid-template-columns: repeat(2, 1fr); } + .image-factory-job-actions > * { flex: 1 1 100%; text-align: center; } +} +.media-source-chip.is-batch { background:color-mix(in srgb,var(--accent) 14%,var(--bg-card)); color:var(--accent); } + +/* Vendoo 1.28.0 · Article Quality Center */ +.quality-center-page { container-type: inline-size; } +.quality-center-heading { align-items:flex-start; gap:24px; } +.quality-center-heading .page-heading-actions { display:flex; flex-wrap:wrap; gap:10px; } +.quality-summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; margin-bottom:16px; } +.quality-summary-grid article { min-width:0; padding:18px 20px; border:1px solid var(--border); border-radius:18px; background:linear-gradient(145deg,var(--surface),color-mix(in srgb,var(--surface) 90%,var(--accent) 10%)); box-shadow:var(--shadow-sm); } +.quality-summary-grid span,.quality-summary-grid small { display:block; color:var(--text-muted); font-size:12px; } +.quality-summary-grid strong { display:block; margin:5px 0 2px; font-size:30px; line-height:1; letter-spacing:-.04em; color:var(--text); } +.quality-toolbar { display:grid; grid-template-columns:minmax(240px,1.6fr) repeat(3,minmax(130px,.55fr)); gap:12px; padding:14px; margin-bottom:16px; } +.quality-toolbar label { display:grid; gap:6px; min-width:0; } +.quality-toolbar label>span { font-size:11px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; } +.quality-toolbar input,.quality-toolbar select { width:100%; min-width:0; } +.quality-workspace { display:grid; grid-template-columns:minmax(350px,.88fr) minmax(440px,1.32fr); gap:16px; align-items:start; } +.quality-list-panel,.quality-detail-panel { min-width:0; padding:16px; } +.quality-list { display:grid; gap:8px; } +.quality-list-item { width:100%; display:grid; grid-template-columns:52px minmax(0,1fr) 54px auto; gap:12px; align-items:center; padding:10px; border:1px solid transparent; border-radius:15px; background:var(--surface-2); color:var(--text); text-align:left; cursor:pointer; transition:.18s ease; } +.quality-list-item:hover { transform:translateY(-1px); border-color:color-mix(in srgb,var(--accent) 45%,var(--border)); background:color-mix(in srgb,var(--surface-2) 88%,var(--accent) 12%); } +.quality-list-item.is-active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 16%,transparent); } +.quality-list-thumb { width:52px; aspect-ratio:1; display:grid; place-items:center; overflow:hidden; border-radius:12px; background:var(--surface); color:var(--text-muted); } +.quality-list-thumb img { width:100%; height:100%; object-fit:cover; } +.quality-list-copy { min-width:0; display:grid; gap:3px; } +.quality-list-copy strong,.quality-list-copy small,.quality-list-copy span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.quality-list-copy strong { font-size:14px; } +.quality-list-copy small { color:var(--text-muted); font-size:11px; } +.quality-list-copy span { color:var(--text-soft); font-size:12px; } +.quality-list-score { width:48px; height:48px; border-radius:50%; display:grid; place-content:center; text-align:center; background:var(--surface); border:2px solid var(--border); } +.quality-list-score b { font-size:15px; line-height:1; } +.quality-list-score em { font-style:normal; font-size:9px; color:var(--text-muted); } +.quality-list-score.is-a,.quality-list-score.is-b { border-color:#39a56f; color:#237c51; } +.quality-list-score.is-c { border-color:#c49a32; color:#8d6811; } +.quality-list-score.is-d,.quality-list-score.is-e { border-color:#d45c55; color:#a33a34; } +.quality-list-state { padding:5px 8px; border-radius:999px; font-size:10px; font-weight:800; white-space:nowrap; } +.quality-list-item.is-ready .quality-list-state { background:rgba(57,165,111,.13); color:#278158; } +.quality-list-item.is-review .quality-list-state { background:rgba(196,154,50,.14); color:#8d6811; } +.quality-list-item.is-blocked .quality-list-state { background:rgba(212,92,85,.13); color:#a33a34; } +.quality-pagination { display:flex; justify-content:center; align-items:center; gap:12px; margin-top:14px; } +.quality-detail-panel { position:sticky; top:16px; max-height:calc(100vh - 118px); overflow:auto; scrollbar-width:thin; } +.quality-detail-empty { min-height:420px; display:grid; place-content:center; text-align:center; gap:8px; color:var(--text-muted); padding:30px; } +.quality-empty-icon { width:64px; height:64px; margin:auto; border-radius:20px; display:grid; place-items:center; font-size:28px; background:color-mix(in srgb,var(--accent) 12%,var(--surface-2)); color:var(--accent); } +.quality-detail-content { display:grid; gap:16px; } +.quality-detail-content.is-loading { opacity:.58; pointer-events:none; } +.quality-detail-head { display:grid; grid-template-columns:92px minmax(0,1fr); gap:16px; align-items:center; } +.quality-score-ring { --quality-score:0deg; width:88px; aspect-ratio:1; border-radius:50%; display:grid; place-content:center; text-align:center; background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(var(--accent) var(--quality-score),var(--surface-3) 0); } +.quality-score-ring[data-grade="A"],.quality-score-ring[data-grade="B"] { background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(#39a56f var(--quality-score),var(--surface-3) 0); } +.quality-score-ring[data-grade="D"],.quality-score-ring[data-grade="E"] { background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(#d45c55 var(--quality-score),var(--surface-3) 0); } +.quality-score-ring strong { font-size:25px; line-height:1; } +.quality-score-ring span { font-size:10px; color:var(--text-muted); } +.quality-detail-head h3 { margin:3px 0 5px; font-size:20px; overflow-wrap:anywhere; } +.quality-detail-head p { margin:0; color:var(--text-muted); } +.quality-readiness { display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; padding:12px 14px; border-radius:14px; background:var(--surface-2); } +.quality-readiness span { color:var(--text-muted); font-size:12px; } +.quality-detail-actions { display:flex; flex-wrap:wrap; gap:8px; } +.quality-financials { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.quality-financials div { padding:12px; border:1px solid var(--border); border-radius:13px; background:var(--surface-2); min-width:0; } +.quality-financials span { display:block; font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } +.quality-financials strong { display:block; margin-top:4px; overflow-wrap:anywhere; } +.quality-issue-tabs { display:flex; gap:6px; overflow:auto; padding-bottom:2px; } +.quality-issue-tabs button { flex:0 0 auto; border:1px solid var(--border); border-radius:999px; padding:7px 11px; background:var(--surface); color:var(--text-muted); font-size:11px; font-weight:700; } +.quality-issue-tabs button.active { background:var(--text); color:var(--surface); border-color:var(--text); } +.quality-issues { display:grid; gap:8px; } +.quality-issue { display:grid; grid-template-columns:34px minmax(0,1fr) auto; gap:10px; align-items:start; padding:12px; border:1px solid var(--border); border-radius:14px; background:var(--surface-2); } +.quality-issue.is-error { border-left:3px solid #d45c55; } +.quality-issue.is-warning { border-left:3px solid #c49a32; } +.quality-issue.is-recommendation { border-left:3px solid #5181ba; } +.quality-issue-icon { width:30px; height:30px; display:grid; place-items:center; border-radius:9px; background:var(--surface); } +.quality-issue strong { display:block; font-size:13px; } +.quality-issue p { margin:3px 0 4px; color:var(--text-soft); font-size:12px; line-height:1.45; } +.quality-issue small { color:var(--text-muted); font-size:10px; } +.quality-issue-points { font-size:11px; font-weight:800; color:var(--text-muted); } +.quality-all-clear { padding:20px; border-radius:14px; text-align:center; background:rgba(57,165,111,.09); color:#278158; } +.quality-all-clear span { display:block; margin-top:4px; font-size:12px; } +.quality-duplicates,.quality-images,.quality-history { border-top:1px solid var(--border); padding-top:14px; } +.quality-duplicates>div:last-child,.quality-images>div:last-child,.quality-history>div:last-child { display:grid; gap:7px; } +.quality-duplicates button { display:flex; justify-content:space-between; gap:12px; width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:12px; background:var(--surface-2); color:var(--text); text-align:left; } +.quality-duplicates button span { color:var(--text-muted); font-size:11px; } +.quality-image-check { display:grid; grid-template-columns:28px minmax(0,1fr); gap:8px; align-items:center; padding:9px 10px; border-radius:11px; background:var(--surface-2); } +.quality-image-check>span { width:26px; height:26px; border-radius:8px; display:grid; place-items:center; background:var(--surface); color:var(--accent); } +.quality-image-check strong,.quality-image-check small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.quality-image-check small { color:var(--text-muted); font-size:10px; margin-top:2px; } +.quality-history-item { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center; padding:8px 10px; border-radius:10px; background:var(--surface-2); font-size:11px; } +.quality-history-item span { color:var(--text-muted); } +.quality-history-item strong { font-size:14px; } +.quality-history-item em { font-style:normal; color:var(--text-muted); } +#quality-score-delta[data-tone="up"] { color:#278158; } +#quality-score-delta[data-tone="down"] { color:#a33a34; } +.quality-empty-note { margin:0; padding:10px; color:var(--text-muted); font-size:12px; } +.quality-loading { min-height:220px; display:flex; align-items:center; justify-content:center; gap:6px; flex-wrap:wrap; color:var(--text-muted); } +.quality-loading span { width:7px; height:7px; border-radius:50%; background:var(--accent); animation:qualityPulse 1s infinite alternate; } +.quality-loading span:nth-child(2){animation-delay:.15s}.quality-loading span:nth-child(3){animation-delay:.3s}.quality-loading p{flex-basis:100%;text-align:center;margin:4px 0 0} +@keyframes qualityPulse { to { opacity:.25; transform:translateY(-4px); } } +.quality-ai-modal { width:min(1120px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; } +.quality-ai-options { display:flex; flex-wrap:wrap; gap:10px; align-items:end; padding:14px; border-radius:14px; background:var(--surface-2); } +.quality-ai-options label:not(.quality-ai-mode) { display:flex; align-items:center; gap:7px; padding:8px 10px; border:1px solid var(--border); border-radius:10px; background:var(--surface); } +.quality-ai-mode { margin-left:auto; display:grid; gap:5px; min-width:160px; } +.quality-ai-status { padding:11px 13px; border-radius:12px; background:color-mix(in srgb,var(--accent) 9%,var(--surface-2)); color:var(--text-soft); font-size:12px; } +.quality-ai-compare { display:grid; grid-template-columns:1fr 1fr; gap:14px; } +.quality-ai-compare section { min-width:0; display:grid; gap:10px; padding:14px; border:1px solid var(--border); border-radius:16px; background:var(--surface-2); } +.quality-ai-compare h3 { margin:0; } +.quality-ai-compare label { display:grid; gap:5px; font-size:11px; font-weight:700; color:var(--text-muted); } +.quality-ai-compare textarea { width:100%; min-height:62px; resize:vertical; } +.quality-ai-compare label:nth-of-type(2) textarea { min-height:190px; } +.quality-ai-reasons { display:flex; flex-wrap:wrap; gap:7px; } +.quality-ai-reasons span { padding:6px 9px; border-radius:999px; background:var(--surface-2); color:var(--text-muted); font-size:10px; } +.quality-ai-modal footer { display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; } + +@container (max-width: 920px) { + .quality-workspace { grid-template-columns:1fr; } + .quality-detail-panel { position:static; max-height:none; } +} +@container (max-width: 680px) { + .quality-summary-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } + .quality-toolbar { grid-template-columns:1fr 1fr; } + .quality-search { grid-column:1/-1; } + .quality-list-item { grid-template-columns:46px minmax(0,1fr) 48px; } + .quality-list-state { grid-column:2/-1; justify-self:start; } + .quality-financials { grid-template-columns:1fr; } +} +@container (max-width: 460px) { + .quality-summary-grid { grid-template-columns:1fr 1fr; gap:8px; } + .quality-summary-grid article { padding:13px; } + .quality-summary-grid strong { font-size:24px; } + .quality-toolbar { grid-template-columns:1fr; } + .quality-search { grid-column:auto; } + .quality-detail-head { grid-template-columns:72px minmax(0,1fr); } + .quality-score-ring { width:68px; } + .quality-detail-actions>* { flex:1 1 100%; } + .quality-list-copy span { display:none; } +} +@media (max-width: 760px) { + .quality-ai-compare { grid-template-columns:1fr; } + .quality-ai-mode { margin-left:0; width:100%; } +} + +/* Vendoo 1.29.0 — Publishing Hardening */ +.publishing-hardened-header { align-items: flex-start; gap: 18px; } +.publishing-toolbar { display:grid; grid-template-columns:minmax(220px,1fr) repeat(3,minmax(130px,auto)); gap:10px; padding:14px 18px; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--surface) 94%,transparent); } +.publishing-toolbar select,.publishing-toolbar input { min-width:0; width:100%; } +.publishing-selection-bar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:10px 18px; border-bottom:1px solid var(--border); background:var(--surface-soft); } +.publishing-selection-bar > span { margin-right:auto; color:var(--text-muted); font-size:.84rem; } +.publishing-job-list { display:grid; gap:10px; padding:14px; } +.publish-operation-row.publishing-job-row { display:grid; grid-template-columns:28px minmax(210px,1.3fr) minmax(120px,.65fr) minmax(150px,.75fr) minmax(100px,.5fr) minmax(160px,1fr) auto; gap:12px; align-items:center; padding:13px; border:1px solid var(--border); border-radius:14px; background:var(--surface); } +.publishing-job-row.status-failed,.publishing-job-row.status-blocked { border-color:color-mix(in srgb,var(--danger) 45%,var(--border)); } +.publishing-job-row.status-running { border-color:color-mix(in srgb,var(--accent) 50%,var(--border)); } +.publishing-job-check { display:grid; place-items:center; } +.publishing-job-check input { width:16px; height:16px; } +.publishing-job-actions { display:flex; align-items:center; justify-content:flex-end; gap:5px; flex-wrap:wrap; } +.publish-operation-meta small { display:block; color:var(--text-muted); margin-top:3px; font-size:.72rem; } +.publishing-pagination { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:14px 18px; border-top:1px solid var(--border); } +.publishing-pagination > div { display:flex; gap:8px; } +.publishing-error-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; padding:16px 18px; } +.publishing-error-summary article { border:1px solid var(--border); border-radius:14px; padding:16px; background:var(--surface); display:grid; gap:4px; } +.publishing-error-summary strong { font-size:1.65rem; } +.publishing-error-summary span { color:var(--text-muted); font-size:.8rem; } +.publishing-event-list { display:grid; gap:8px; padding:0 18px 18px; } +.publishing-event-row { display:grid; grid-template-columns:10px minmax(0,1fr) auto auto; gap:12px; align-items:center; padding:12px 14px; border:1px solid var(--border); border-radius:12px; background:var(--surface); } +.publishing-event-row.level-error { border-color:color-mix(in srgb,var(--danger) 40%,var(--border)); } +.publishing-event-dot { width:8px; height:8px; border-radius:50%; background:var(--accent); } +.publishing-event-row.level-error .publishing-event-dot { background:var(--danger); } +.publishing-event-row > div { min-width:0; } +.publishing-event-row strong,.publishing-event-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.publishing-event-row small { color:var(--text-muted); margin-top:3px; } +.publishing-event-type { font-size:.72rem; padding:4px 8px; border-radius:999px; background:var(--surface-soft); color:var(--text-muted); } +.operation-status.status-awaiting_user { color:#7b4cc7; background:color-mix(in srgb,#8b5cf6 14%,transparent); } +.operation-status.status-retry_wait { color:#a15c00; background:color-mix(in srgb,#f59e0b 14%,transparent); } +.operation-status.status-ended,.operation-status.status-cancelled { color:var(--text-muted); background:var(--surface-soft); } + +@media (max-width: 1240px) { + .publish-operation-row.publishing-job-row { grid-template-columns:28px minmax(200px,1fr) minmax(130px,.7fr) minmax(150px,.8fr) auto; } + .publishing-job-row .publish-operation-meta:nth-of-type(3), .publishing-job-row .publish-operation-error { grid-column:2 / -2; } + .publishing-job-actions { grid-column:-2 / -1; grid-row:1 / span 2; } +} +@media (max-width: 820px) { + .publishing-toolbar { grid-template-columns:1fr 1fr; } + .publishing-toolbar .publish-search-field { grid-column:1 / -1; } + .publish-operation-row.publishing-job-row { grid-template-columns:24px minmax(0,1fr) auto; align-items:start; } + .publishing-job-row .publish-operation-product { grid-column:2 / 3; } + .publishing-job-row .publish-operation-meta,.publishing-job-row .publish-operation-error { grid-column:2 / -1 !important; } + .publishing-job-actions { grid-column:2 / -1; grid-row:auto; justify-content:flex-start; } + .publishing-error-summary { grid-template-columns:1fr; } + .publishing-event-row { grid-template-columns:10px minmax(0,1fr) auto; } + .publishing-event-type { display:none; } +} +@media (max-width: 520px) { + .publishing-toolbar { grid-template-columns:1fr; } + .publishing-toolbar .publish-search-field { grid-column:auto; } + .publishing-pagination { align-items:stretch; flex-direction:column; } + .publishing-pagination > div,.publishing-pagination button { width:100%; } + .publishing-hardened-header > div:last-child { width:100%; display:grid; grid-template-columns:1fr; } +} + +/* Vendoo 1.30.0 — Operations Center */ +.operations-center { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--border); min-width: 0; } +.operations-center-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; } +.operations-center-head h3 { margin: 3px 0 5px; font-size: 20px; } +.operations-center-head p { margin: 0; color: var(--text-muted); max-width: 760px; } +.operations-tabs { display: flex; gap: 6px; padding: 5px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); overflow-x: auto; margin-bottom: 16px; } +.operations-tab { min-height: 38px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); font: inherit; font-weight: 700; white-space: nowrap; cursor: pointer; } +.operations-tab:hover { color: var(--text); background: var(--bg-card); } +.operations-tab.active { color: white; background: var(--accent); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 25%, transparent); } +.operations-panel { display: none; min-width: 0; } +.operations-panel.active { display: block; } +.operations-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.operations-update-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.operations-update-upload { grid-column: 1 / -1; } +.operations-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); box-shadow: 0 8px 24px rgba(20, 24, 35, .04); } +.operations-card > header { display: flex; gap: 11px; align-items: flex-start; margin-bottom: 15px; } +.operations-card > header > span { flex: 0 0 38px; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: color-mix(in srgb, var(--accent) 11%, transparent); } +.operations-card h4, .operations-list-head h4 { margin: 0 0 3px; font-size: 14px; } +.operations-card p, .operations-list-head p { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; } +.operation-field { display: grid; gap: 6px; margin-bottom: 11px; min-width: 0; } +.operation-field > span { font-size: 11px; font-weight: 750; color: var(--text-muted); } +.operation-field input, .operation-field select { width: 100%; min-width: 0; height: 39px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-page); color: var(--text); } +.operation-check { display: flex; gap: 9px; align-items: flex-start; padding: 9px 0; cursor: pointer; } +.operation-check input { margin-top: 3px; accent-color: var(--accent); } +.operation-check span { min-width: 0; } +.operation-check strong, .operation-check small { display: block; } +.operation-check strong { font-size: 12px; } +.operation-check small { margin-top: 2px; color: var(--text-muted); font-size: 10px; line-height: 1.4; } +.operation-check.warning strong { color: var(--warning, #b7791f); } +.operation-inline-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } +.operation-dropzone { display: grid; place-items: center; min-height: 112px; padding: 15px; border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--accent) 4%, var(--bg-page)); text-align: center; cursor: pointer; } +.operation-dropzone:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg-page)); } +.operation-dropzone > span { color: var(--accent); margin-bottom: 6px; } +.operation-dropzone strong, .operation-dropzone small { display: block; } +.operation-dropzone strong { font-size: 12px; } +.operation-dropzone small { margin-top: 4px; color: var(--text-muted); font-size: 10px; } +.operation-file-state { min-height: 30px; margin: 8px 0; padding: 7px 9px; border-radius: 8px; background: var(--bg-page); color: var(--text-muted); font: 600 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.operations-list-head { display: flex; justify-content: space-between; align-items: end; gap: 12px; margin: 20px 0 10px; } +.operations-backup-list { display: grid; gap: 7px; } +.operations-backup-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) minmax(120px, auto) auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-card); min-width: 0; } +.operations-backup-row.is-missing { opacity: .55; } +.operation-backup-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.operation-backup-main { min-width: 0; } +.operation-backup-main strong, .operation-backup-main span, .operation-backup-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operation-backup-main strong { font-size: 12px; } +.operation-backup-main span { margin-top: 2px; color: var(--text-muted); font-size: 10px; } +.operation-backup-main small { margin-top: 2px; color: var(--text-muted); font-size: 9px; } +.operation-backup-hash { color: var(--text-muted); font-size: 9px; } +.operation-backup-actions { display: flex; gap: 5px; } +.operation-backup-actions .icon-button { width: 32px; height: 32px; } +.operations-pending { display: flex; align-items: center; gap: 12px; padding: 12px; margin-bottom: 14px; border: 1px solid color-mix(in srgb, var(--warning, #d97706) 45%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--warning, #d97706) 8%, var(--bg-card)); } +.operations-pending > span { color: var(--warning, #d97706); } +.operations-pending > div { flex: 1; min-width: 0; } +.operations-pending strong, .operations-pending p { display: block; margin: 0; } +.operations-pending p { margin-top: 2px; color: var(--text-muted); font-size: 11px; } +.operation-version-card { display: grid; gap: 4px; padding: 14px; border-radius: 11px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-page)); } +.operation-version-card strong { font-size: 18px; } +.operation-version-card span { color: var(--text-muted); font-size: 10px; } +.operation-migration-list { display: grid; gap: 6px; margin-top: 12px; max-height: 220px; overflow: auto; } +.operation-migration-list > div { display: flex; gap: 8px; align-items: flex-start; padding: 7px; border-radius: 8px; background: var(--bg-page); } +.operation-migration-list > div > span { color: var(--success, #20875d); } +.operation-migration-list p { min-width: 0; } +.operation-migration-list strong, .operation-migration-list small { display: block; } +.operation-migration-list small { color: var(--text-muted); font-size: 9px; } +.operation-card-actions { display: flex; gap: 7px; flex-wrap: wrap; } +.operation-result { display: grid; gap: 3px; min-height: 52px; margin-top: 10px; padding: 10px; border-radius: 9px; background: var(--bg-page); color: var(--text-muted); font-size: 10px; } +.operation-result strong { color: var(--text); font-size: 12px; } +.operations-instruction { display: flex; gap: 11px; padding: 13px; margin-top: 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); } +.operations-instruction > span { color: var(--accent); } +.operations-instruction strong, .operations-instruction p { display: block; margin: 0; } +.operations-instruction p { margin-top: 3px; color: var(--text-muted); font-size: 11px; line-height: 1.5; } +.operations-extension-summary > article { display: flex; gap: 12px; align-items: center; padding: 15px; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); } +.operations-extension-summary > article > span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +.operations-extension-summary strong, .operations-extension-summary p { display: block; margin: 0; } +.operations-extension-summary p { color: var(--text-muted); font-size: 11px; } +.operations-extension-packages { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 10px; } +.operations-extension-packages article { display: flex; gap: 8px; align-items: center; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); } +.operations-extension-packages article > span { color: var(--text-muted); } +.operations-extension-packages article.is-ok > span { color: var(--success, #20875d); } +.operations-extension-packages article.is-warning > span { color: var(--warning, #d97706); } +.operations-extension-packages strong, .operations-extension-packages small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operations-extension-packages strong { font-size: 11px; } +.operations-extension-packages small { color: var(--text-muted); font-size: 9px; } +.operations-extension-clients { display: grid; gap: 7px; } +.operations-extension-client { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); } +.operation-live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); } +.operations-extension-client.is-live .operation-live-dot { background: var(--success, #20875d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success, #20875d) 15%, transparent); } +.operations-extension-client strong, .operations-extension-client span, .operations-extension-client small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operations-extension-client strong { font-size: 11px; } +.operations-extension-client span, .operations-extension-client small { color: var(--text-muted); font-size: 9px; } +.operations-extension-client b { font-size: 9px; color: var(--text-muted); } +.operations-extension-client.is-live b { color: var(--success, #20875d); } + +@media (max-width: 1180px) { + .operations-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .operations-restore-card { grid-column: 1 / -1; } + .operations-extension-packages { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 760px) { + .operations-center-head { display: grid; } + .operations-center-head .secondary-btn { width: 100%; } + .operations-grid, .operations-update-grid { grid-template-columns: minmax(0, 1fr); } + .operations-restore-card, .operations-update-upload { grid-column: auto; } + .operations-backup-row { grid-template-columns: 36px minmax(0, 1fr) auto; } + .operation-backup-hash { display: none; } + .operations-pending { align-items: flex-start; flex-wrap: wrap; } + .operations-pending .secondary-btn { width: 100%; } +} +@media (max-width: 520px) { + .operation-inline-fields, .operations-extension-packages { grid-template-columns: minmax(0, 1fr); } + .operations-backup-row { grid-template-columns: 32px minmax(0, 1fr); } + .operation-backup-actions { grid-column: 1 / -1; justify-content: flex-end; } + .operations-extension-client { grid-template-columns: 10px minmax(0, 1fr); } + .operations-extension-client b { grid-column: 2; } +} + +/* Vendoo 1.31.0 — Multi-user, RBAC & server security */ +.role-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: 14px; + margin: 18px 0 24px; +} +.role-card, +.security-card, +.security-env-guide { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface, var(--card-bg)); + padding: 16px; + min-width: 0; +} +.role-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.role-card p, +.security-card p, +.security-env-guide p { color: var(--text-muted); margin: 0; line-height: 1.55; } +.role-badge.manager { background: color-mix(in srgb, #7c3aed 14%, transparent); color: #6d28d9; } +.role-badge.publisher { background: color-mix(in srgb, #0284c7 14%, transparent); color: #0369a1; } +.role-badge.viewer { background: color-mix(in srgb, #64748b 16%, transparent); color: #475569; } + +.role-permission-matrix { display: grid; gap: 20px; } +.role-matrix-group h4 { margin: 0 0 9px; } +.role-matrix-table { + border: 1px solid var(--border); + border-radius: 14px; + overflow-x: auto; + background: var(--surface, var(--card-bg)); +} +.role-matrix-head, +.role-matrix-row { + display: grid; + grid-template-columns: minmax(230px, 1.8fr) repeat(5, minmax(96px, .7fr)); + min-width: 760px; + align-items: center; +} +.role-matrix-head { background: var(--surface-soft, rgba(127,127,127,.06)); font-size: 12px; } +.role-matrix-head > *, .role-matrix-row > * { padding: 10px 12px; border-right: 1px solid var(--border); } +.role-matrix-head > *:last-child, .role-matrix-row > *:last-child { border-right: 0; } +.role-matrix-row { border-top: 1px solid var(--border); } +.role-matrix-row i { font-style: normal; text-align: center; font-weight: 800; } +.role-matrix-row i.is-allowed { color: var(--success, #15803d); } +.role-matrix-row i.is-denied { color: var(--text-muted); } + +.security-panel-heading, +.security-lock-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; +} +.security-panel-heading h3, +.security-lock-head h4 { margin: 2px 0 5px; } +.security-panel-heading p, +.security-lock-head p { margin: 0; color: var(--text-muted); } +.security-status-summary { margin-bottom: 12px; } +.security-score { + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + background: var(--surface, var(--card-bg)); +} +.security-score > span { width: 34px; height: 34px; } +.security-score.is-good { border-color: color-mix(in srgb, #16a34a 42%, var(--border)); } +.security-score.is-warning { border-color: color-mix(in srgb, #d97706 48%, var(--border)); } +.security-score strong { display: block; font-size: 16px; } +.security-score p { margin: 3px 0 0; color: var(--text-muted); } +.security-warning-list { display: grid; gap: 8px; margin-bottom: 16px; } +.security-warning, +.security-ok { + display: flex; + gap: 10px; + align-items: flex-start; + border-radius: 12px; + padding: 11px 13px; +} +.security-warning { background: color-mix(in srgb, #f59e0b 11%, transparent); color: #92400e; } +.security-ok { background: color-mix(in srgb, #22c55e 10%, transparent); color: #166534; } +.security-warning p { margin: 0; } +.security-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 22px; } +.security-card h4 { margin: 0 0 12px; } +.security-card dl { display: grid; grid-template-columns: minmax(115px, .8fr) minmax(0, 1.2fr); gap: 9px 14px; margin: 0; } +.security-card dt { color: var(--text-muted); } +.security-card dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.security-lock-list { display: grid; gap: 8px; margin-bottom: 22px; } +.security-lock-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface, var(--card-bg)); +} +.security-lock-row p { margin: 3px 0 0; color: var(--text-muted); font-size: 12px; } +.security-env-guide pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--surface-soft, rgba(127,127,127,.06)); + border: 1px solid var(--border); + border-radius: 12px; + padding: 13px; +} + +.listing-lock-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 14px; + padding: 12px 14px; + border-radius: 13px; + border: 1px solid var(--border); +} +.listing-lock-banner.is-mine { background: color-mix(in srgb, #22c55e 9%, var(--surface, transparent)); border-color: color-mix(in srgb, #22c55e 38%, var(--border)); } +.listing-lock-banner.is-foreign { background: color-mix(in srgb, #f59e0b 10%, var(--surface, transparent)); border-color: color-mix(in srgb, #f59e0b 44%, var(--border)); } +.listing-lock-banner > span { width: 24px; height: 24px; flex: 0 0 auto; } +.listing-lock-banner strong { display: block; } +.listing-lock-banner p { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; } + +.audit-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1.6fr) minmax(150px, .8fr) 145px 145px 135px auto auto; + gap: 8px; + align-items: center; + margin-bottom: 12px; +} +.audit-toolbar > * { min-width: 0; } +.audit-summary { color: var(--text-muted); font-size: 13px; margin-bottom: 8px; } +.audit-user { display: grid; gap: 2px; } +.audit-user small, .audit-action small { color: var(--text-muted); } +.audit-action p { margin: 4px 0 0; color: var(--text-muted); overflow-wrap: anywhere; } +.audit-pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 14px; } + +body[data-user-role="viewer"] .danger-zone, +body[data-user-role="publisher"] .danger-zone { display: none !important; } + +@media (max-width: 1050px) { + .audit-toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .audit-toolbar button { width: 100%; } +} +@media (max-width: 760px) { + .security-grid { grid-template-columns: 1fr; } + .security-panel-heading, .security-lock-head, .security-lock-row { align-items: stretch; flex-direction: column; } + .security-card dl { grid-template-columns: 1fr; gap: 3px; } + .security-card dd { margin-bottom: 8px; } + .audit-toolbar { grid-template-columns: 1fr; } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/vendoo-icons.js b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/vendoo-icons.js new file mode 100644 index 0000000..d262ec6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/public/vendoo-icons.js @@ -0,0 +1,67 @@ +/* Vendoo Icons 1.0 — compact local SVG icon set based on Lucide geometry (ISC). */ +(() => { + const ICONS = { + check: '', + heart: '', + 'heart-filled': '', + pencil: '', + mail: '', + plus: '', + download: '', + 'trash-2': '', + 'zoom-in': '', + 'zoom-out': '', + maximize: '', + x: '', + 'triangle-alert': '', + sparkles: '', + image: '', + list: '', + folder: '', + 'folder-open': '', + copy: '', + 'external-link': '', + puzzle: '', + 'shield-check': '', + 'shield-alert': '', + 'alert-triangle': '', + 'database-backup': '', + 'calendar-clock': '', + 'scan-check': '', + 'package-check': '', + 'package-x': '', + 'plug-zap': '', + 'file-archive': '', + 'package-open': '', + 'radio-tower': '', + 'badge-check': '', + 'upload-cloud': '', + 'check-circle-2': '', + 'archive': '', + 'refresh-cw': '', + 'rotate-ccw': '', + 'circle-help': '', + chrome: '', + firefox: '', + compass: '', + edge: '', + }; + + function svg(name, options = {}) { + const size = Number(options.size || 18); + const label = options.label ? ` role="img" aria-label="${String(options.label).replace(/"/g, '"')}"` : ' aria-hidden="true"'; + return `${ICONS[name] || ICONS.image}`; + } + + function render(root = document) { + root.querySelectorAll('[data-vd-icon]').forEach(node => { + const name = node.dataset.vdIcon; + const size = node.dataset.vdIconSize || 18; + node.innerHTML = svg(name, { size }); + node.classList.add('vd-icon-host'); + }); + } + + window.VendooIcons = Object.freeze({ svg, render, names: Object.keys(ICONS) }); + document.addEventListener('DOMContentLoaded', () => render()); +})(); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-backup.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-backup.sh new file mode 100644 index 0000000..ad0069c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-backup.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +write_sha256() { + file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" > "$file.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" > "$file.sha256" + else + echo "[WARN] Weder sha256sum noch shasum verfügbar; keine Prüfsummendatei erzeugt." >&2 + fi +} + +target="${1:-./vendoo-docker-backup-$(date +%Y%m%d-%H%M%S)}" +mkdir -p "$target" +absolute_target="$(cd "$target" && pwd)" + +docker compose config -q +restart=0 +if docker compose ps --status running --services | grep -qx vendoo; then + restart=1 + docker compose stop vendoo +fi +cleanup() { + code=$? + if [ "$restart" -eq 1 ]; then docker compose up -d vendoo >/dev/null; fi + exit "$code" +} +trap cleanup EXIT INT TERM + +docker compose run --rm --no-deps --user 0:0 \ + -v "$absolute_target":/backup \ + --entrypoint sh vendoo -eu -c \ + 'cd /app/data && tar czf /backup/vendoo-data.tar.gz db uploads backups operations logs' +write_sha256 "$target/vendoo-data.tar.gz" + +trap - EXIT INT TERM +if [ "$restart" -eq 1 ]; then docker compose up -d vendoo >/dev/null; fi +echo "Backup erstellt: $target/vendoo-data.tar.gz" +[ "$restart" -eq 0 ] || echo "Vendoo wurde wieder gestartet." diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-entrypoint.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..1aacd2d --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +for dir in \ + "$(dirname "${VENDOO_DB_PATH:-/app/data/db/vendoo.db}")" \ + "${VENDOO_UPLOADS_DIR:-/app/data/uploads}" \ + "${VENDOO_BACKUP_DIR:-/app/data/backups}" \ + "${VENDOO_OPERATIONS_DIR:-/app/data/operations}" \ + "${VENDOO_LOG_DIR:-/app/data/logs}" \ + "$(dirname "${VENDOO_CONFIG_PATH:-/app/data/config/vendoo.env}")"; do + mkdir -p "$dir" + test -w "$dir" || { echo "[FATAL] Kein Schreibzugriff auf $dir (Container-UID 1000)." >&2; exit 70; } +done + +if [ "${NODE_ENV:-production}" = "production" ] && [ "${VENDOO_REQUIRE_SETUP_TOKEN:-true}" = "true" ]; then + token="${VENDOO_SETUP_TOKEN:-}" + if [ "${#token}" -lt 32 ]; then + echo "[FATAL] VENDOO_SETUP_TOKEN muss im Produktionsbetrieb mindestens 32 Zeichen lang sein." >&2 + exit 78 + fi +fi + +if [ "${VENDOO_TRUST_PROXY:-false}" != "false" ] && [ -z "${PUBLIC_BASE_URL:-}" ]; then + echo "[WARN] VENDOO_TRUST_PROXY ist aktiv, aber PUBLIC_BASE_URL fehlt. HTTPS-/Cookie-Erkennung sorgfältig prüfen." >&2 +fi + +exec "$@" diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-restore.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-restore.sh new file mode 100644 index 0000000..e756ffd --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/docker-restore.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +verify_sha256() { + checksum=$1 + directory=$2 + if command -v sha256sum >/dev/null 2>&1; then + (cd "$directory" && sha256sum -c "$(basename "$checksum")") + elif command -v shasum >/dev/null 2>&1; then + (cd "$directory" && shasum -a 256 -c "$(basename "$checksum")") + else + echo "[WARN] Weder sha256sum noch shasum verfügbar; Prüfsumme kann nicht geprüft werden." >&2 + fi +} + +archive="${1:-}" +confirm="${2:-}" +[ -n "$archive" ] && [ -f "$archive" ] || { + echo "Aufruf: scripts/docker-restore.sh /pfad/vendoo-data.tar.gz --confirm" >&2 + exit 64 +} +[ "$confirm" = "--confirm" ] || { + echo "[ABBRUCH] Restore ersetzt die aktuellen Docker-Daten. Erneut mit --confirm aufrufen." >&2 + exit 64 +} + +archive_dir="$(cd "$(dirname "$archive")" && pwd)" +archive_name="$(basename "$archive")" +archive_abs="$archive_dir/$archive_name" +checksum_file="$archive_abs.sha256" + +docker compose config -q +if [ -f "$checksum_file" ]; then + echo "Prüfe SHA-256 des Restore-Archivs ..." + verify_sha256 "$checksum_file" "$archive_dir" +else + echo "[WARN] Keine Prüfsummendatei $checksum_file gefunden; Archivintegrität kann nicht automatisch bestätigt werden." >&2 +fi + +echo "Erstelle vor dem Restore ein Sicherheitsbackup ..." +pre_restore="./vendoo-pre-restore-$(date +%Y%m%d-%H%M%S)" +"$(dirname "$0")/docker-backup.sh" "$pre_restore" + +echo "Stoppe Vendoo und stelle die persistenten Daten wieder her ..." +docker compose stop vendoo +restart_on_error() { + code=$? + if [ "$code" -ne 0 ]; then + echo "[FEHLER] Restore fehlgeschlagen. Vendoo wird zur Diagnose erneut gestartet." >&2 + docker compose up -d vendoo >/dev/null 2>&1 || true + fi + exit "$code" +} +trap restart_on_error EXIT INT TERM + +docker compose run --rm --no-deps --user 0:0 \ + -v "$archive_dir":/input:ro \ + --entrypoint sh vendoo -eu -c \ + 'for dir in db uploads backups operations logs; do + mkdir -p "/app/data/$dir" + find "/app/data/$dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + done + tar xzf "/input/$1" -C /app/data' sh "$archive_name" + +trap - EXIT INT TERM +docker compose up -d vendoo +echo "Restore eingespielt. Sicherheitsbackup: $pre_restore/vendoo-data.tar.gz" +echo "Jetzt /readyz, Login, Benutzer, Artikel, Bilder und Operations Center prüfen." diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.ps1 new file mode 100644 index 0000000..bf2bf16 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.ps1 @@ -0,0 +1,183 @@ +# Vendoo 1.36.0 – kontrollierter GitHub-Erstimport +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$RepoUrl = 'https://github.com/Masterluke77/vendoo.git' +$PullRequestUrl = 'https://github.com/Masterluke77/vendoo/pull/9' +$Branch = 'release/1.36.0-theme-manager' +$WorkRoot = Join-Path $env:TEMP ('vendoo-github-deploy-' + [Guid]::NewGuid().ToString('N')) +$CloneDir = Join-Path $WorkRoot 'repo' + +function Info($Text) { Write-Host "[INFO] $Text" -ForegroundColor Cyan } +function Ok($Text) { Write-Host "[OK] $Text" -ForegroundColor Green } +function Warn($Text) { Write-Host "[WARN] $Text" -ForegroundColor Yellow } +function Fail($Text) { Write-Host "[FEHLER] $Text" -ForegroundColor Red } + +function Test-Command([string]$Name) { + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Ensure-Git { + if (Test-Command 'git.exe') { return } + Warn 'Git für Windows wurde nicht gefunden.' + if (Test-Command 'winget.exe') { + $answer = Read-Host 'Git jetzt automatisch über winget installieren? (ja/nein)' + if ($answer -eq 'ja') { + & winget.exe install --id Git.Git --exact --source winget --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw 'Git-Installation über winget ist fehlgeschlagen.' } + $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') + } + } + if (-not (Test-Command 'git.exe')) { throw 'Git für Windows wird benötigt. Starte den Assistenten nach der Git-Installation erneut.' } +} + +function Invoke-Git([string[]]$Arguments, [string]$WorkingDirectory = $CloneDir) { + Push-Location $WorkingDirectory + try { + & git.exe @Arguments + if ($LASTEXITCODE -ne 0) { throw "git $($Arguments -join ' ') fehlgeschlagen (Exit-Code $LASTEXITCODE)." } + } finally { Pop-Location } +} + +function Copy-ProjectClean { + if (-not (Test-Command 'node.exe')) { + throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.' + } + Info 'Bereite den bereinigten Git-Arbeitsordner mit dem plattformübergreifenden Staging-Builder vor ...' + $builder = Join-Path $Root 'tools\prepare-git-staging.mjs' + $result = Invoke-NodeProcess -ScriptPath $builder -Arguments @($Root, $CloneDir) -WorkingDirectory $Root -Prefix 'Staging' + if ($result.ExitCode -ne 0) { + $result.Lines | ForEach-Object { Write-Host $_ -ForegroundColor Red } + throw 'Der sichere Git-Staging-Aufbau ist fehlgeschlagen.' + } + $result.Lines | ForEach-Object { Write-Host $_ } +} + +function Invoke-NodeProcess { + param( + [Parameter(Mandatory=$true)][string]$ScriptPath, + [string[]]$Arguments = @(), + [string]$WorkingDirectory = $CloneDir, + [string]$Prefix = 'Node' + ) + $id = [Guid]::NewGuid().ToString('N') + $stdoutPath = Join-Path $WorkRoot ("$Prefix-$id-stdout.txt") + $stderrPath = Join-Path $WorkRoot ("$Prefix-$id-stderr.txt") + $nodePath = (Get-Command node.exe -ErrorAction Stop).Source + $allArguments = @($ScriptPath) + $Arguments + $argumentLine = ($allArguments | ForEach-Object { '"' + ([string]$_).Replace('"','\"') + '"' }) -join ' ' + $process = Start-Process -FilePath $nodePath ` + -ArgumentList $argumentLine ` + -WorkingDirectory $WorkingDirectory ` + -NoNewWindow ` + -Wait ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + $lines = @() + if (Test-Path -LiteralPath $stdoutPath) { $lines += @(Get-Content -LiteralPath $stdoutPath -Encoding UTF8 -ErrorAction SilentlyContinue) } + if (Test-Path -LiteralPath $stderrPath) { $lines += @(Get-Content -LiteralPath $stderrPath -Encoding UTF8 -ErrorAction SilentlyContinue) } + return [pscustomobject]@{ ExitCode = [int]$process.ExitCode; Lines = $lines } +} + +function Run-SafetyGate { + $ReportDir = Join-Path $Root 'logs' + $ReportPath = Join-Path $ReportDir 'github-deploy-safety-report.txt' + if (-not (Test-Command 'node.exe')) { + throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.' + } + + Info 'Führe das Vendoo Git-Sicherheitsgate über den vollständigen Staging-Arbeitsbaum aus ...' + $result = Invoke-NodeProcess -ScriptPath 'tools/verify-git-safety.mjs' -WorkingDirectory $CloneDir -Prefix 'SafetyGate' + foreach ($line in $result.Lines) { + if ($line -match '^Git-Sicherheitsgate fehlgeschlagen|^- ') { Write-Host $line -ForegroundColor Red } + elseif ($line -match '^\[WARN\]') { Write-Host $line -ForegroundColor Yellow } + else { Write-Host $line } + } + + if ($result.ExitCode -ne 0) { + New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null + @( + 'Vendoo Git-Sicherheitsgate – vollständiger Fehlerbericht', + ('Zeitpunkt: ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')), + ('Projekt: ' + $Root), + ('Staging: ' + $CloneDir), + '', + $result.Lines + ) | Set-Content -LiteralPath $ReportPath -Encoding UTF8 + Fail "Das Gate hat $($result.Lines.Count) Diagnosezeilen erzeugt. Bericht: $ReportPath" + try { Start-Process notepad.exe -ArgumentList ('"' + $ReportPath + '"') | Out-Null } catch {} + throw 'Git-Sicherheitsgate ist fehlgeschlagen. Die konkreten Treffer wurden oben angezeigt und im Bericht gespeichert.' + } + + Remove-Item -LiteralPath $ReportPath -Force -ErrorAction SilentlyContinue + $forbidden = Get-ChildItem -LiteralPath $CloneDir -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { + ($_.Name -match '^\.env(?:\..+)?$' -and $_.Name -notin @('.env.example','.env.docker.example')) -or + $_.Extension -in @('.db','.sqlite','.sqlite3','.log','.key','.pem','.pfx','.p12') + } + if ($forbidden) { + throw ('Verbotene Dateien im Git-Staging: ' + (($forbidden | Select-Object -First 20 -ExpandProperty FullName) -join ', ')) + } + Ok 'Git-Sicherheitsgate erfolgreich: keine lokalen Secrets oder Laufzeitdaten im Import.' +} + +try { + Write-Host '' + Write-Host 'Vendoo 1.36.0 – GitHub Deploy Assistent' -ForegroundColor Green + Write-Host 'Repository: Masterluke77/vendoo (privat)' -ForegroundColor DarkGray + Write-Host '' + + Ensure-Git + New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null + + Info 'Klone die vorhandene GitHub-Baseline ...' + & git.exe clone $RepoUrl $CloneDir + if ($LASTEXITCODE -ne 0) { throw 'Repository konnte nicht geklont werden. Die Git-Anmeldung öffnet sich normalerweise automatisch im Browser.' } + + Push-Location $CloneDir + try { + & git.exe ls-remote --exit-code --heads origin $Branch 1>$null 2>$null + $branchExists = ($LASTEXITCODE -eq 0) + } finally { Pop-Location } + if ($branchExists) { + Invoke-Git @('fetch','origin',$Branch) + Invoke-Git @('switch','--track','-c',$Branch,("origin/" + $Branch)) + } else { + Invoke-Git @('switch','-c',$Branch) + } + + Copy-ProjectClean + Run-SafetyGate + + Invoke-Git @('config','user.name','Markus Müller') + Invoke-Git @('config','user.email','163446896+Masterluke77@users.noreply.github.com') + Invoke-Git @('add','--all') + + Push-Location $CloneDir + try { + $status = & git.exe status --short + if (-not $status) { Warn 'Es gibt keine Änderungen gegenüber GitHub.'; exit 0 } + Write-Host '' + Write-Host $status + Write-Host '' + } finally { Pop-Location } + + $confirm = Read-Host 'Geprüften Vendoo-Stand als Release-Branch zu GitHub übertragen? Zum Fortfahren PUSH eingeben' + if ($confirm -cne 'PUSH') { Warn 'Deployment abgebrochen. Auf GitHub wurde nichts verändert.'; exit 0 } + + Invoke-Git @('commit','-m','fix: import Vendoo 1.36.0 secure theme manager and accessibility foundation') + Invoke-Git @('push','--set-upstream','origin',$Branch) + Ok "Branch $Branch wurde erfolgreich übertragen." + + Info 'Öffne jetzt Draft Pull Request #9 im Browser ...' + Start-Process $PullRequestUrl + Write-Host '' + Write-Host 'Nächster Schritt im Browser: Pull Request #5 und CI prüfen. Erst nach grüner CI nach main mergen.' -ForegroundColor Yellow +} catch { + Fail $_.Exception.Message + exit 1 +} finally { + if (Test-Path -LiteralPath $WorkRoot) { + Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.sh new file mode 100644 index 0000000..f73ae0e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/github-deploy.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh +set -eu +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +REPO_URL="https://github.com/Masterluke77/vendoo.git" +BRANCH="release/1.36.0-theme-manager" +PR_URL="https://github.com/Masterluke77/vendoo/pull/9" +WORK_DIR=$(mktemp -d) +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT INT TERM + +command -v git >/dev/null 2>&1 || { echo "Git fehlt." >&2; exit 2; } +command -v node >/dev/null 2>&1 || { echo "Node.js wird für den sicheren GitHub-Import benötigt." >&2; exit 2; } + +git clone "$REPO_URL" "$WORK_DIR/repo" +cd "$WORK_DIR/repo" +if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch origin "$BRANCH" + git switch --track -c "$BRANCH" "origin/$BRANCH" +else + git switch -c "$BRANCH" +fi + +node "$ROOT_DIR/tools/prepare-git-staging.mjs" "$ROOT_DIR" "$WORK_DIR/repo" + +GATE_OUTPUT="$WORK_DIR/git-safety-output.txt" +if ! node tools/verify-git-safety.mjs >"$GATE_OUTPUT" 2>&1; then + cat "$GATE_OUTPUT" >&2 + mkdir -p "$ROOT_DIR/logs" + { + echo "Vendoo Git-Sicherheitsgate – vollständiger Fehlerbericht" + echo "Projekt: $ROOT_DIR" + echo + cat "$GATE_OUTPUT" + } > "$ROOT_DIR/logs/github-deploy-safety-report.txt" + echo "Git-Sicherheitsgate fehlgeschlagen. Details: $ROOT_DIR/logs/github-deploy-safety-report.txt" >&2 + exit 1 +fi +cat "$GATE_OUTPUT" +rm -f "$ROOT_DIR/logs/github-deploy-safety-report.txt" + +git config user.name "Markus Müller" +git config user.email "163446896+Masterluke77@users.noreply.github.com" +git add --all +git status --short +printf 'Zum Pushen exakt PUSH eingeben: ' +read -r answer +[ "$answer" = "PUSH" ] || { echo "Abgebrochen."; exit 0; } +git commit -m "feat: import Vendoo 1.36.0 secure theme manager and accessibility foundation" +git push --set-upstream origin "$BRANCH" +echo "Branch übertragen. Draft Pull Request #9 öffnen: $PR_URL" diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/install-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/install-local-flux.ps1 new file mode 100644 index 0000000..01ac09e --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/install-local-flux.ps1 @@ -0,0 +1,803 @@ +param( + [ValidateSet('auto','nvidia','amd','intel','cpu')] + [string]$Gpu = 'auto', + [switch]$InstallModel, + [switch]$EnableAutostart, + [switch]$StartNow, + [switch]$Force, + [switch]$UpdateOnly, + [switch]$NonInteractive +) + +$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$InstallRoot = Join-Path $LocalAiDir 'comfyui' +$PortableRoot = Join-Path $InstallRoot 'ComfyUI_windows_portable' +$ComfyDir = Join-Path $PortableRoot 'ComfyUI' +$EmbeddedPython = Join-Path $PortableRoot 'python_embeded\python.exe' +$ModelDir = Join-Path $ComfyDir 'models\checkpoints' +$ModelPath = Join-Path $ModelDir 'flux1-schnell-fp8.safetensors' +$WorkflowDir = Join-Path $LocalAiDir 'workflows' +$WorkflowPath = Join-Path $WorkflowDir 'vendoo_flux_schnell_api.json' +$LogDir = Join-Path $VendooDir 'logs' +$LogFile = Join-Path $LogDir 'local-flux-install.log' +$StateFile = Join-Path $LocalAiDir 'install-state.json' +$EnvFile = Join-Path $VendooDir '.env' +$DownloadDir = Join-Path $LocalAiDir 'downloads' +$StartScript = Join-Path $LocalAiDir 'start-local-flux.ps1' +$StopScript = Join-Path $LocalAiDir 'stop-local-flux.ps1' +$TaskName = 'Vendoo Local FLUX' +$ComfyPort = 8188 +$ComfyUrl = "http://127.0.0.1:$ComfyPort" +$GitHubApi = 'https://api.github.com/repos/Comfy-Org/ComfyUI/releases/latest' +$ModelUrl = 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true' + +$InstallStartedAt = Get-Date +$LastConsoleProgressAt = [DateTime]::MinValue + +New-Item -ItemType Directory -Force -Path $LocalAiDir,$InstallRoot,$WorkflowDir,$LogDir,$DownloadDir | Out-Null + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + + +function Write-Utf8Bom([string]$Path, [string]$Content) { + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($Path, $Content, $utf8Bom) +} + +function Write-Log([string]$Message, [string]$Level = 'INFO') { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + Add-Content -Path $LogFile -Value $line -Encoding utf8 + if (-not $NonInteractive) { + $color = 'Gray' + if ($Level -eq 'OK') { $color = 'Green' } + elseif ($Level -eq 'WARN') { $color = 'Yellow' } + elseif ($Level -eq 'ERROR') { $color = 'Red' } + Write-Host $line -ForegroundColor $color + } +} + +function Save-State([string]$Status, [string]$Phase, [int]$Progress, [string]$Message, [hashtable]$Extra) { + $previous = $null + if (Test-Path -LiteralPath $StateFile) { + try { $previous = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json } catch {} + } + $startedAt = if ($Phase -eq 'preflight' -and $Status -eq 'running') { $InstallStartedAt.ToString('o') } elseif ($previous -and $previous.started_at) { [string]$previous.started_at } else { $InstallStartedAt.ToString('o') } + $state = [ordered]@{ + status = $Status + phase = $Phase + progress = [math]::Max(0, [math]::Min(100, $Progress)) + message = $Message + started_at = $startedAt + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + elapsed_seconds = [math]::Round(((Get-Date) - ([DateTime]::Parse($startedAt))).TotalSeconds) + install_root = $InstallRoot + comfy_url = $ComfyUrl + model_path = $ModelPath + workflow_path = $WorkflowPath + log_file = $LogFile + requested_install_model = [bool]$InstallModel + requested_enable_autostart = [bool]$EnableAutostart + requested_start_now = [bool]$StartNow + } + if ($Extra) { + foreach ($key in $Extra.Keys) { $state[$key] = $Extra[$key] } + } + $tempState = "$StateFile.tmp" + Write-Utf8NoBom -Path $tempState -Content ($state | ConvertTo-Json -Depth 6) + Move-Item -LiteralPath $tempState -Destination $StateFile -Force +} + +function Set-EnvVar([string]$Key, [string]$Value) { + $content = if (Test-Path $EnvFile) { Get-Content $EnvFile -Raw } else { '' } + $line = "$Key=$Value" + if ($content -match "(?m)^$([regex]::Escape($Key))=") { + $content = [regex]::Replace($content, "(?m)^$([regex]::Escape($Key))=.*$", $line) + } else { + $content = $content.TrimEnd() + "`r`n" + $line + "`r`n" + } + Write-Utf8NoBom -Path $EnvFile -Content $content +} + +function Get-EnvValue([string]$Key) { + if (-not (Test-Path $EnvFile)) { return '' } + $content = Get-Content $EnvFile -Raw + $match = [regex]::Match($content, "(?m)^$([regex]::Escape($Key))=(.*)$") + if ($match.Success) { return $match.Groups[1].Value.Trim() } + return '' +} + +function New-LocalToken { + $bytes = New-Object byte[] 32 + [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_') +} + +function Test-Command([string]$Name) { + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Get-GpuType { + if ($Gpu -ne 'auto') { return $Gpu } + try { + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + $joined = ($names -join ' ').ToLowerInvariant() + if ($joined -match 'nvidia|geforce|quadro|rtx|gtx') { return 'nvidia' } + if ($joined -match 'amd|radeon') { return 'amd' } + if ($joined -match 'intel') { return 'intel' } + } catch {} + return 'cpu' +} + +function Get-FreeSpaceGb([string]$Path) { + try { + $resolved = (Resolve-Path -LiteralPath $Path).Path + $root = [System.IO.Path]::GetPathRoot($resolved) + $drive = New-Object System.IO.DriveInfo -ArgumentList $root + return [math]::Round($drive.AvailableFreeSpace / 1GB, 1) + } catch { + Write-Log "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)" 'WARN' + return 0 + } +} + +function Get-7ZipCandidates { + $items = New-Object System.Collections.Generic.List[string] + try { + $command = Get-Command 7z.exe -ErrorAction SilentlyContinue + if ($command -and $command.Source) { [void]$items.Add([string]$command.Source) } + } catch {} + + $known = New-Object System.Collections.Generic.List[string] + if ($env:ProgramFiles) { [void]$known.Add((Join-Path $env:ProgramFiles '7-Zip\7z.exe')) } + if (${env:ProgramFiles(x86)}) { [void]$known.Add((Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')) } + if ($env:LOCALAPPDATA) { [void]$known.Add((Join-Path $env:LOCALAPPDATA 'Programs\7-Zip\7z.exe')) } + foreach ($path in $known) { + if ($path -and (Test-Path -LiteralPath $path)) { [void]$items.Add([string]$path) } + } + return @($items | Select-Object -Unique) +} + +function Refresh-ProcessPath { + $machine = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [System.Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machine;$user" +} + +function Ensure-7Zip { + Save-State 'running' 'dependency-7zip' 24 '7-Zip wird geprüft.' @{} + $candidates = @(Get-7ZipCandidates) + if ($candidates.Count -gt 0) { + $path = [string]$candidates[0] + Write-Log "7-Zip gefunden: $path" 'OK' + return $path + } + + if (Test-Command 'winget.exe') { + Write-Log '7-Zip fehlt. Installation über winget wird gestartet.' 'WARN' + Save-State 'running' 'install-7zip' 25 '7-Zip wird automatisch installiert.' @{} + & winget.exe install --id 7zip.7zip -e --silent --accept-source-agreements --accept-package-agreements | Out-Null + $wingetExit = $LASTEXITCODE + Refresh-ProcessPath + Start-Sleep -Seconds 2 + $candidates = @(Get-7ZipCandidates) + if ($candidates.Count -gt 0) { + $path = [string]$candidates[0] + Write-Log "7-Zip installiert und gefunden: $path" 'OK' + return $path + } + Write-Log "winget beendete die 7-Zip-Installation mit Exit-Code $wingetExit, aber 7z.exe wurde nicht gefunden." 'WARN' + } + + throw '7-Zip ist erforderlich und konnte nicht automatisch installiert oder gefunden werden. Installiere 7-Zip und starte die FLUX-Installation danach erneut.' +} + +function Get-DirectoryMetrics([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { + return @{ file_count = 0; bytes = [long]0; megabytes = 0 } + } + try { + $files = @(Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue) + $bytes = [long](($files | Measure-Object -Property Length -Sum).Sum) + return @{ + file_count = $files.Count + bytes = $bytes + megabytes = [math]::Round($bytes / 1MB, 1) + } + } catch { + return @{ file_count = 0; bytes = [long]0; megabytes = 0 } + } +} + +function Stop-Stale7ZipForArchive([string]$ArchivePath, [string]$Destination) { + try { + $archiveEscaped = [regex]::Escape($ArchivePath) + $destinationEscaped = [regex]::Escape($Destination) + $items = @(Get-CimInstance Win32_Process -Filter "Name='7z.exe' OR Name='7zG.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and ($_.CommandLine -match $archiveEscaped -or $_.CommandLine -match $destinationEscaped) }) + foreach ($item in $items) { + Write-Log "Beende veralteten 7-Zip-Prozess PID $($item.ProcessId) für diese Installation." 'WARN' + Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Log "Veraltete 7-Zip-Prozesse konnten nicht geprüft werden: $($_.Exception.Message)" 'WARN' + } +} + +function Invoke-7ZipExtract([string]$SevenZipPath, [string]$ArchivePath, [string]$Destination) { + if (-not (Test-Path -LiteralPath $SevenZipPath)) { throw "7-Zip wurde nicht gefunden: $SevenZipPath" } + if (-not (Test-Path -LiteralPath $ArchivePath)) { throw "ComfyUI-Archiv wurde nicht gefunden: $ArchivePath" } + + Stop-Stale7ZipForArchive -ArchivePath $ArchivePath -Destination $Destination + $stdoutFile = Join-Path $LogDir 'local-flux-7zip-output.log' + $stderrFile = Join-Path $LogDir 'local-flux-7zip-error.log' + Remove-Item -LiteralPath $stdoutFile,$stderrFile -Force -ErrorAction SilentlyContinue + + $arguments = "x `"$ArchivePath`" -o`"$Destination`" -y -bsp1 -bso1 -bse1" + Write-Log "Starte 7-Zip-Entpackung: `"$SevenZipPath`" x `"$ArchivePath`" -o`"$Destination`"" 'INFO' + Save-State 'running' 'extract-comfyui' 33 '7-Zip wurde gestartet. ComfyUI wird entpackt.' @{ seven_zip = $SevenZipPath; archive = $ArchivePath; extract_root = $Destination; extract_files = 0; extract_mb = 0 } + + $process = Start-Process -FilePath $SevenZipPath -ArgumentList $arguments -WorkingDirectory $DownloadDir -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + if (-not $process) { throw '7-Zip-Prozess konnte nicht gestartet werden.' } + + $started = Get-Date + $lastReportedFiles = -1 + $lastActivity = Get-Date + $timeoutMinutes = 45 + + while (-not $process.HasExited) { + Start-Sleep -Seconds 5 + $process.Refresh() + $metrics = Get-DirectoryMetrics $Destination + if ($metrics.file_count -ne $lastReportedFiles) { + $lastReportedFiles = $metrics.file_count + $lastActivity = Get-Date + } + $elapsed = [math]::Round(((Get-Date) - $started).TotalSeconds) + $progress = 34 + [math]::Min(16, [math]::Floor($elapsed / 20)) + $message = "ComfyUI wird entpackt: $($metrics.file_count) Dateien · $($metrics.megabytes) MB · ${elapsed}s" + Save-State 'running' 'extract-comfyui' $progress $message @{ + seven_zip = $SevenZipPath + archive = $ArchivePath + extract_root = $Destination + extract_files = $metrics.file_count + extract_bytes = [long]$metrics.bytes + extract_mb = $metrics.megabytes + extract_elapsed_seconds = $elapsed + extract_process_id = $process.Id + extract_stdout = $stdoutFile + extract_stderr = $stderrFile + } + if (-not $NonInteractive -and ((Get-Date) - $script:LastConsoleProgressAt).TotalSeconds -ge 10) { + Write-Host " [..] $message" -ForegroundColor Cyan + $script:LastConsoleProgressAt = Get-Date + } + if (((Get-Date) - $started).TotalMinutes -gt $timeoutMinutes) { + try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {} + throw "7-Zip-Entpackung wurde nach $timeoutMinutes Minuten wegen Zeitüberschreitung abgebrochen." + } + if (((Get-Date) - $lastActivity).TotalMinutes -gt 3 -and $metrics.file_count -eq 0) { + try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {} + throw '7-Zip zeigt seit 3 Minuten keine Extraktionsaktivität. Prüfe Archiv, Virenscanner und freien Speicher.' + } + } + + $process.WaitForExit() + $process.Refresh() + + $rawExitCode = $null + try { + $rawExitCode = $process.ExitCode + } catch { + Write-Log "7-Zip Exit-Code konnte über das Process-Objekt nicht direkt gelesen werden: $($_.Exception.Message)" 'WARN' + } + + $stdoutTail = if (Test-Path -LiteralPath $stdoutFile) { (Get-Content -LiteralPath $stdoutFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' } + $stderrTail = if (Test-Path -LiteralPath $stderrFile) { (Get-Content -LiteralPath $stderrFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' } + $finalMetrics = Get-DirectoryMetrics $Destination + + $exitCode = $rawExitCode + if ($null -eq $exitCode) { + $hasSuccessMarker = $stdoutTail -match '(?im)^Everything is Ok\s*$' + $hasPlausibleResult = ($finalMetrics.file_count -ge 20 -and $finalMetrics.bytes -ge 50MB) + $hasErrorOutput = -not [string]::IsNullOrWhiteSpace($stderrTail) + + if ($hasSuccessMarker -and $hasPlausibleResult -and -not $hasErrorOutput) { + $exitCode = 0 + Write-Log "7-Zip Exit-Code war unter Windows PowerShell 5.1 leer. Erfolg wurde sicher über 'Everything is Ok', leeres Fehlerprotokoll und $($finalMetrics.file_count) Dateien / $($finalMetrics.megabytes) MB bestätigt." 'WARN' + } else { + $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ') + throw "7-Zip-Prozess wurde beendet, aber der Exit-Code konnte nicht ermittelt werden und die Erfolgsprüfung war nicht eindeutig. $details" + } + } + + if ([int]$exitCode -ne 0) { + $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ') + throw "ComfyUI-Archiv konnte nicht entpackt werden (7-Zip Exit-Code $exitCode). $details" + } + if ($finalMetrics.file_count -lt 20 -or $finalMetrics.bytes -lt 50MB) { + throw "7-Zip meldete Erfolg, aber das Entpackungsergebnis ist unvollständig ($($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB)." + } + + Save-State 'running' 'extract-comfyui' 51 "ComfyUI wurde entpackt: $($finalMetrics.file_count) Dateien · $($finalMetrics.megabytes) MB." @{ + seven_zip = $SevenZipPath + archive = $ArchivePath + extract_root = $Destination + extract_files = $finalMetrics.file_count + extract_bytes = [long]$finalMetrics.bytes + extract_mb = $finalMetrics.megabytes + extract_exit_code = $exitCode + } + Write-Log "7-Zip-Entpackung abgeschlossen: $($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB." 'OK' +} + +function Get-LatestPortableAsset([string]$GpuType) { + Write-Log 'Frage aktuelle ComfyUI-Portable-Version über GitHub API ab.' + $release = Invoke-RestMethod -Uri $GitHubApi -Headers @{ 'User-Agent' = 'Vendoo-Local-Flux-Installer' } + $assets = @($release.assets) + if ($assets.Count -eq 0) { throw 'Die aktuelle ComfyUI-Version enthält keine Download-Assets.' } + + $patterns = @() + switch ($GpuType) { + 'nvidia' { $patterns = @('windows_portable.*nvidia.*\.7z$','windows_portable.*cu13.*\.7z$','windows_portable.*\.7z$') } + 'amd' { $patterns = @('windows_portable.*amd.*\.7z$','windows_portable.*\.7z$') } + 'intel' { $patterns = @('windows_portable.*intel.*\.7z$','windows_portable.*\.7z$') } + default { $patterns = @('windows_portable.*\.7z$') } + } + + foreach ($pattern in $patterns) { + $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if ($asset) { return $asset } + } + throw "Kein passendes ComfyUI-Windows-Portable-Paket für GPU-Typ '$GpuType' gefunden." +} + +function Format-DownloadDuration([double]$Seconds) { + if ($Seconds -lt 0 -or [double]::IsInfinity($Seconds) -or [double]::IsNaN($Seconds)) { return 'unbekannt' } + $span = [TimeSpan]::FromSeconds([math]::Round($Seconds)) + if ($span.TotalHours -ge 1) { return ('{0}h {1}m' -f [math]::Floor($span.TotalHours), $span.Minutes) } + if ($span.TotalMinutes -ge 1) { return ('{0}m {1}s' -f [math]::Floor($span.TotalMinutes), $span.Seconds) } + return ('{0}s' -f [math]::Max(0, $span.Seconds)) +} + +function Remove-StaleVendooBitsJobs { + try { + Import-Module BitsTransfer -ErrorAction SilentlyContinue + $jobs = @(Get-BitsTransfer -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'Vendoo *' }) + foreach ($job in $jobs) { + try { Remove-BitsTransfer -BitsJob $job -Confirm:$false -ErrorAction SilentlyContinue } catch {} + } + if ($jobs.Count -gt 0) { + Write-Log "$($jobs.Count) veraltete(r) Vendoo-BITS-Auftrag/Aufträge wurden entfernt." 'WARN' + } + } catch {} +} + +function Invoke-ResumableHttpDownload( + [string]$Url, + [string]$PartialPath, + [string]$Label, + [string]$Phase, + [int]$ProgressStart, + [int]$ProgressEnd +) { + Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue + + $maxAttempts = 5 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $handler = $null + $client = $null + $request = $null + $response = $null + $networkStream = $null + $fileStream = $null + + try { + $resumeFrom = 0L + if (Test-Path -LiteralPath $PartialPath) { + $resumeFrom = [long](Get-Item -LiteralPath $PartialPath).Length + } + + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $true + $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::None + $client = [System.Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromHours(24) + + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url) + [void]$request.Headers.TryAddWithoutValidation('User-Agent', 'Vendoo-Local-Flux-Installer/1.14.5') + if ($resumeFrom -gt 0) { + $request.Headers.Range = [System.Net.Http.Headers.RangeHeaderValue]::Parse("bytes=$resumeFrom-") + Write-Log "$Label wird ab $([math]::Round($resumeFrom / 1MB, 1)) MB fortgesetzt." 'OK' + } + + $response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + $statusCode = [int]$response.StatusCode + + if (-not $response.IsSuccessStatusCode) { + throw "HTTP-Fehler $statusCode ($($response.ReasonPhrase))." + } + + $append = ($resumeFrom -gt 0 -and $statusCode -eq 206) + if ($resumeFrom -gt 0 -and -not $append) { + Write-Log 'Der Downloadserver unterstützt an dieser Stelle keine Fortsetzung. Die Teildatei wird kontrolliert neu begonnen.' 'WARN' + Remove-Item -LiteralPath $PartialPath -Force -ErrorAction SilentlyContinue + $resumeFrom = 0L + } + + $remainingLength = 0L + if ($null -ne $response.Content.Headers.ContentLength) { + $remainingLength = [long]$response.Content.Headers.ContentLength + } + $totalBytes = if ($remainingLength -gt 0) { $resumeFrom + $remainingLength } else { 0L } + + $networkStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $mode = if ($append) { [System.IO.FileMode]::Append } else { [System.IO.FileMode]::Create } + $fileStream = [System.IO.File]::Open($PartialPath, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) + + $buffer = New-Object byte[] (1MB) + $downloaded = $resumeFrom + $lastSampleBytes = $downloaded + $lastSampleAt = Get-Date + $lastStateAt = [DateTime]::MinValue + $lastConsoleAt = [DateTime]::MinValue + $smoothedBytesPerSecond = 0.0 + + while (($read = $networkStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $fileStream.Write($buffer, 0, $read) + $downloaded += $read + $now = Get-Date + + if (($now - $lastSampleAt).TotalSeconds -ge 2) { + $sampleSeconds = [math]::Max(0.1, ($now - $lastSampleAt).TotalSeconds) + $sampleSpeed = ($downloaded - $lastSampleBytes) / $sampleSeconds + if ($smoothedBytesPerSecond -le 0) { $smoothedBytesPerSecond = $sampleSpeed } + else { $smoothedBytesPerSecond = ($smoothedBytesPerSecond * 0.65) + ($sampleSpeed * 0.35) } + $lastSampleBytes = $downloaded + $lastSampleAt = $now + } + + if (($now - $lastStateAt).TotalSeconds -ge 2) { + $percent = 0 + if ($totalBytes -gt 0) { + $percent = [math]::Min(100, [math]::Round(($downloaded / $totalBytes) * 100, 1)) + } + $overall = $ProgressStart + if ($totalBytes -gt 0) { + $overall = $ProgressStart + [math]::Round((($ProgressEnd - $ProgressStart) * $percent) / 100) + } + $downloadedMb = [math]::Round($downloaded / 1MB, 1) + $totalMb = if ($totalBytes -gt 0) { [math]::Round($totalBytes / 1MB, 1) } else { 0 } + $speedMbps = [math]::Round($smoothedBytesPerSecond / 1MB, 1) + $etaSeconds = -1 + if ($totalBytes -gt 0 -and $smoothedBytesPerSecond -gt 0) { + $etaSeconds = [math]::Max(0, ($totalBytes - $downloaded) / $smoothedBytesPerSecond) + } + $etaText = Format-DownloadDuration $etaSeconds + $message = if ($totalBytes -gt 0) { + "${Label}: $percent% · $downloadedMb / $totalMb MB · $speedMbps MB/s · Rest ca. $etaText" + } else { + "${Label}: $downloadedMb MB · $speedMbps MB/s · Gesamtgröße wird ermittelt" + } + + Save-State 'running' $Phase $overall $message @{ + download_percent = $percent + bytes_transferred = [long]$downloaded + bytes_total = [long]$totalBytes + download_speed_mbps = $speedMbps + eta_seconds = [math]::Round($etaSeconds) + download_transport = 'http-stream-resumable' + download_attempt = $attempt + partial_path = $PartialPath + } + + if (-not $NonInteractive) { + $progressPercent = if ($totalBytes -gt 0) { [int][math]::Min(100, [math]::Round($percent)) } else { 0 } + Write-Progress -Activity "$Label wird heruntergeladen" -Status $message -PercentComplete $progressPercent + if (($now - $lastConsoleAt).TotalSeconds -ge 10) { + Write-Host " [DOWNLOAD] $message" -ForegroundColor Cyan + $lastConsoleAt = $now + } + } + $lastStateAt = $now + } + } + + $fileStream.Flush() + if (-not $NonInteractive) { + Write-Progress -Activity "$Label wird heruntergeladen" -Completed + } + return + } catch { + $errorText = $_.Exception.Message + if ($attempt -ge $maxAttempts) { + throw "$Label konnte nach $maxAttempts Versuchen nicht vollständig geladen werden. Letzter Fehler: $errorText" + } + + $currentBytes = 0L + if (Test-Path -LiteralPath $PartialPath) { + try { $currentBytes = [long](Get-Item -LiteralPath $PartialPath).Length } catch {} + } + Write-Log "$Label-Download wurde unterbrochen: $errorText. Automatische Fortsetzung in 5 Sekunden (bereits $([math]::Round($currentBytes / 1MB, 1)) MB)." 'WARN' + Save-State 'running' $Phase $ProgressStart "$Label wird nach einer Netzwerkunterbrechung automatisch fortgesetzt." @{ + bytes_transferred = $currentBytes + download_transport = 'http-stream-resumable' + download_attempt = $attempt + retry_in_seconds = 5 + } + Start-Sleep -Seconds 5 + } finally { + if ($fileStream) { try { $fileStream.Dispose() } catch {} } + if ($networkStream) { try { $networkStream.Dispose() } catch {} } + if ($response) { try { $response.Dispose() } catch {} } + if ($request) { try { $request.Dispose() } catch {} } + if ($client) { try { $client.Dispose() } catch {} } + if ($handler) { try { $handler.Dispose() } catch {} } + } + } +} + +function Download-File([string]$Url, [string]$Destination, [string]$Label, [long]$MinimumBytes = 1) { + $partial = "$Destination.part" + + if (Test-Path -LiteralPath $Destination) { + $size = [long](Get-Item -LiteralPath $Destination).Length + if ($size -ge $MinimumBytes) { + Write-Log "$Label bereits vollständig vorhanden; Download wird übersprungen." 'OK' + return + } + + Write-Log "$Label ist unvollständig. Bereits vorhandene Daten werden für die Fortsetzung übernommen." 'WARN' + if (-not (Test-Path -LiteralPath $partial) -or (Get-Item -LiteralPath $partial).Length -lt $size) { + Move-Item -LiteralPath $Destination -Destination $partial -Force + } else { + Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue + } + } + + Remove-StaleVendooBitsJobs + + $existingBytes = 0L + if (Test-Path -LiteralPath $partial) { + $existingBytes = [long](Get-Item -LiteralPath $partial).Length + } + + $phase = if ($Label -like 'FLUX*') { 'download-model' } else { 'download-comfyui' } + $progressStart = if ($phase -eq 'download-model') { 65 } else { 10 } + $progressEnd = if ($phase -eq 'download-model') { 85 } else { 23 } + + if ($existingBytes -gt 0) { + Write-Log "$Label-Teildownload gefunden: $([math]::Round($existingBytes / 1MB, 1)) MB. Download wird fortgesetzt." 'OK' + } else { + Write-Log "$Label wird mit sichtbarem Fortschritt heruntergeladen." + } + + Save-State 'running' $phase $progressStart "$Label-Download wird vorbereitet." @{ + download_percent = 0 + bytes_transferred = $existingBytes + download_transport = 'http-stream-resumable' + partial_path = $partial + } + + Invoke-ResumableHttpDownload -Url $Url -PartialPath $partial -Label $Label -Phase $phase -ProgressStart $progressStart -ProgressEnd $progressEnd + + if (-not (Test-Path -LiteralPath $partial)) { throw "$Label wurde nicht heruntergeladen." } + $downloadedSize = [long](Get-Item -LiteralPath $partial).Length + if ($downloadedSize -lt $MinimumBytes) { + throw "$Label ist mit $downloadedSize Bytes unerwartet klein und wahrscheinlich unvollständig. Die Teildatei bleibt für eine Fortsetzung erhalten." + } + + Move-Item -LiteralPath $partial -Destination $Destination -Force + Save-State 'running' $phase $progressEnd "$Label vollständig heruntergeladen." @{ + download_percent = 100 + bytes_transferred = $downloadedSize + bytes_total = $downloadedSize + download_transport = 'http-stream-resumable' + } + Write-Log "$Label heruntergeladen ($([math]::Round($downloadedSize / 1MB, 1)) MB)." 'OK' +} + +function Write-Workflow { + $workflow = @' +{ + "3": { "inputs": { "ckpt_name": "flux1-schnell-fp8.safetensors" }, "class_type": "CheckpointLoaderSimple" }, + "4": { "inputs": { "text": "__PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" }, + "5": { "inputs": { "text": "__NEGATIVE_PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" }, + "6": { "inputs": { "width": 1024, "height": 1024, "batch_size": 1 }, "class_type": "EmptyLatentImage" }, + "7": { "inputs": { "seed": "__SEED__", "steps": 4, "cfg": 1, "sampler_name": "euler", "scheduler": "simple", "denoise": 1, "model": ["3", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0] }, "class_type": "KSampler" }, + "8": { "inputs": { "samples": ["7", 0], "vae": ["3", 2] }, "class_type": "VAEDecode" }, + "9": { "inputs": { "filename_prefix": "Vendoo/flux_schnell", "images": ["8", 0] }, "class_type": "SaveImage" } +} +'@ + Write-Utf8NoBom -Path $WorkflowPath -Content $workflow + Write-Log 'FLUX-Prompt-Workflow wurde erstellt.' 'OK' +} + +function Write-StartStopScripts { + $runtimeStartTemplate = Join-Path $ScriptDir 'runtime-start-local-flux.ps1' + $runtimeStopTemplate = Join-Path $ScriptDir 'runtime-stop-local-flux.ps1' + if (-not (Test-Path -LiteralPath $runtimeStartTemplate)) { + throw "FLUX-Runtime-Startvorlage fehlt: $runtimeStartTemplate" + } + if (-not (Test-Path -LiteralPath $runtimeStopTemplate)) { + throw "FLUX-Runtime-Stoppvorlage fehlt: $runtimeStopTemplate" + } + Copy-Item -LiteralPath $runtimeStartTemplate -Destination $StartScript -Force + Copy-Item -LiteralPath $runtimeStopTemplate -Destination $StopScript -Force + Write-Log 'Gehärtete FLUX-Runtime-Skripte wurden installiert.' 'OK' +} + +function Register-AutostartTask { + if (-not $EnableAutostart) { return } + $taskCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartScript`"" + & schtasks.exe /Create /SC ONLOGON /TN $TaskName /TR $taskCommand /F | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Autostart-Aufgabe konnte nicht erstellt werden.' } + Write-Log 'Autostart-Aufgabe für lokalen FLUX-Server erstellt.' 'OK' +} + +function Test-ComfyReachable { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 5 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +try { + Save-State 'running' 'preflight' 2 'Vorprüfung läuft.' @{} + $gpuType = Get-GpuType + $freeGb = Get-FreeSpaceGb $LocalAiDir + Write-Log "Erkannte GPU-Klasse: $gpuType" + Write-Log "Freier Speicher: $freeGb GB" + if ($InstallModel -and $freeGb -lt 28 -and -not $Force) { + throw 'Für ComfyUI plus FLUX.1-schnell FP8 werden mindestens 28 GB freier Speicher empfohlen.' + } + + $alreadyInstalled = (Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py')) + if ($alreadyInstalled -and -not $Force -and -not $UpdateOnly) { + Write-Log 'ComfyUI ist bereits installiert. Neuinstallation wird übersprungen.' 'OK' + } elseif ($UpdateOnly -and $alreadyInstalled) { + Save-State 'running' 'update' 15 'ComfyUI wird aktualisiert.' @{ gpu = $gpuType } + $updateScript = Join-Path $PortableRoot 'update\update_comfyui.bat' + if (Test-Path $updateScript) { + & cmd.exe /d /c "`"$updateScript`"" | Out-Null + Write-Log 'ComfyUI-Update ausgeführt.' 'OK' + } else { + Write-Log 'Kein portables Update-Skript gefunden; aktuelle Installation bleibt unverändert.' 'WARN' + } + } else { + Save-State 'running' 'download-comfyui' 10 'ComfyUI Portable wird geladen.' @{ gpu = $gpuType } + $asset = Get-LatestPortableAsset $gpuType + $archive = Join-Path $DownloadDir $asset.name + Download-File $asset.browser_download_url $archive 'ComfyUI Portable' 100MB + + Save-State 'running' 'extract-comfyui' 30 'ComfyUI Portable wird für die Entpackung vorbereitet.' @{ asset = $asset.name; gpu = $gpuType } + $sevenZip = Ensure-7Zip + $extractTemp = Join-Path $LocalAiDir 'extract-temp' + $reuseExtractedPortable = $false + + if (Test-Path -LiteralPath $extractTemp) { + $resumePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match '^ComfyUI_windows_portable' -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'python_embeded\python.exe')) + } | + Select-Object -First 1 + + if ($resumePortable) { + $resumeMetrics = Get-DirectoryMetrics $extractTemp + if ($resumeMetrics.file_count -ge 20 -and $resumeMetrics.bytes -ge 50MB) { + $reuseExtractedPortable = $true + Write-Log "Vollständiger temporärer ComfyUI-Entpackstand wird wiederverwendet: $($resumeMetrics.file_count) Dateien, $($resumeMetrics.megabytes) MB." 'OK' + Save-State 'running' 'extract-comfyui' 51 "Vorhandener vollständiger Entpackstand wird fortgesetzt: $($resumeMetrics.file_count) Dateien · $($resumeMetrics.megabytes) MB." @{ + seven_zip = $sevenZip + archive = $archive + extract_root = $extractTemp + extract_files = $resumeMetrics.file_count + extract_bytes = [long]$resumeMetrics.bytes + extract_mb = $resumeMetrics.megabytes + extract_reused = $true + extract_exit_code = 0 + } + } + } + + if (-not $reuseExtractedPortable) { + Write-Log 'Vorhandenes temporäres Entpackungsverzeichnis ist nicht vollständig und wird bereinigt.' 'WARN' + Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction Stop + } + } + + if (-not $reuseExtractedPortable) { + New-Item -ItemType Directory -Force -Path $extractTemp | Out-Null + Invoke-7ZipExtract -SevenZipPath $sevenZip -ArchivePath $archive -Destination $extractTemp + } + + Save-State 'running' 'install-comfyui' 52 'Entpackte ComfyUI-Struktur wird geprüft.' @{ asset = $asset.name; gpu = $gpuType; extract_reused = $reuseExtractedPortable } + $sourcePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^ComfyUI_windows_portable' -and (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) } | + Select-Object -First 1 + if (-not $sourcePortable) { + $mainFile = Get-ChildItem -LiteralPath $extractTemp -Filter 'main.py' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '[\\/]ComfyUI[\\/]main\.py$' } | + Select-Object -First 1 + if ($mainFile) { + $comfyFolder = Split-Path -Parent $mainFile.FullName + $sourcePortablePath = Split-Path -Parent $comfyFolder + $sourcePortable = Get-Item -LiteralPath $sourcePortablePath + } + } + if (-not $sourcePortable) { throw 'Entpacktes ComfyUI-Portable-Verzeichnis wurde nicht gefunden. Prüfe local-ai\extract-temp und das 7-Zip-Protokoll.' } + if (Test-Path -LiteralPath $PortableRoot) { Remove-Item -LiteralPath $PortableRoot -Recurse -Force } + Move-Item -LiteralPath $sourcePortable.FullName -Destination $PortableRoot + if (-not (Test-Path -LiteralPath $EmbeddedPython) -or -not (Test-Path -LiteralPath (Join-Path $ComfyDir 'main.py'))) { + throw 'ComfyUI wurde verschoben, aber Python oder main.py fehlen. Die Archivstruktur ist unerwartet.' + } + Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction SilentlyContinue + Save-State 'running' 'install-comfyui' 54 'ComfyUI Portable ist vollständig installiert.' @{ asset = $asset.name; gpu = $gpuType; comfy_installed = $true } + Write-Log 'ComfyUI Portable installiert.' 'OK' + } + + Save-State 'running' 'configure' 55 'Sichere localhost-Konfiguration wird erstellt.' @{ gpu = $gpuType } + Write-Workflow + Write-StartStopScripts + $token = Get-EnvValue 'LOCAL_IMAGE_TOKEN' + if ([string]::IsNullOrWhiteSpace($token)) { $token = New-LocalToken } + Set-EnvVar 'LOCAL_IMAGE_ENABLED' 'true' + Set-EnvVar 'LOCAL_IMAGE_URL' $ComfyUrl + Set-EnvVar 'LOCAL_IMAGE_TOKEN' $token + Set-EnvVar 'LOCAL_IMAGE_WORKFLOW_PATH' 'local-ai/workflows/vendoo_flux_schnell_api.json' + Set-EnvVar 'LOCAL_IMAGE_INSTALL_PATH' 'local-ai/comfyui/ComfyUI_windows_portable' + Set-EnvVar 'LOCAL_IMAGE_REQUIRE_TOKEN' 'true' + + if ($InstallModel) { + Save-State 'running' 'download-model' 65 'FLUX.1-schnell FP8 wird geladen.' @{ gpu = $gpuType } + New-Item -ItemType Directory -Force -Path $ModelDir | Out-Null + Download-File $ModelUrl $ModelPath 'FLUX.1-schnell FP8' 1GB + if ((Get-Item $ModelPath).Length -lt 1GB) { throw 'Die heruntergeladene FLUX-Datei ist unerwartet klein und wahrscheinlich unvollständig.' } + Write-Log 'FLUX.1-schnell FP8 installiert.' 'OK' + } else { + Write-Log 'Modell-Download wurde nicht ausgewählt.' 'WARN' + } + + Save-State 'running' 'autostart' 88 'Autostart wird eingerichtet.' @{ gpu = $gpuType } + Register-AutostartTask + + if ($StartNow) { + Save-State 'running' 'start' 94 'Lokaler FLUX-Server wird gestartet.' @{ gpu = $gpuType } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $StartScript + Start-Sleep -Seconds 8 + } + + $reachable = Test-ComfyReachable + $modelInstalled = Test-Path $ModelPath + $message = 'Lokales FLUX / ComfyUI ist installiert.' + if (-not $modelInstalled) { $message += ' Das FLUX-Modell muss noch installiert werden.' } + if ($StartNow -and -not $reachable) { $message += ' Der Server wurde gestartet, ist aber noch nicht erreichbar; prüfe das Runtime-Log.' } + Save-State 'completed' 'done' 100 $message @{ gpu = $gpuType; comfy_installed = $true; model_installed = $modelInstalled; reachable = $reachable; autostart = [bool]$EnableAutostart } + Write-Log $message 'OK' + exit 0 +} catch { + $message = $_.Exception.Message + $position = $_.InvocationInfo.PositionMessage + $failedProgress = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $previousState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $failedProgress = [int]$previousState.progress + } + } catch {} + Write-Log $message 'ERROR' + if ($position) { Write-Log $position 'ERROR' } + Save-State 'failed' 'error' $failedProgress $message @{ error_position = $position; can_resume = $true; comfy_installed = ((Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py'))); model_installed = (Test-Path $ModelPath) } + exit 1 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-start-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-start-local-flux.ps1 new file mode 100644 index 0000000..4e44413 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-start-local-flux.ps1 @@ -0,0 +1,210 @@ +param( + [int]$StartupTimeoutSeconds = 180, + [ValidateSet('auto','fast','balanced','lowvram')] + [string]$Profile = 'auto' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if ((Split-Path -Leaf $ScriptDir) -eq 'local-ai') { + $VendooDir = Split-Path -Parent $ScriptDir +} else { + $VendooDir = Split-Path -Parent $ScriptDir +} +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$PortableRoot = Join-Path $LocalAiDir 'comfyui\ComfyUI_windows_portable' +$Python = Join-Path $PortableRoot 'python_embeded\python.exe' +$Main = Join-Path $PortableRoot 'ComfyUI\main.py' +$LogDir = Join-Path $VendooDir 'logs' +$StdoutLog = Join-Path $LogDir 'local-flux-runtime.log' +$StderrLog = Join-Path $LogDir 'local-flux-runtime-error.log' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' +$ComfyUrl = 'http://127.0.0.1:8188' +$ComfyPort = 8188 + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$env:NO_COLOR = '1' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Remove-Ansi([string]$Text) { + if ([string]::IsNullOrEmpty($Text)) { return '' } + $pattern = ([string][char]27) + '\\[[0-?]*[ -/]*[@-~]' + return [regex]::Replace($Text, $pattern, '') +} + +function Read-Tail([string]$Path, [int]$Count = 16) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + try { + return @(Get-Content -LiteralPath $Path -Tail $Count -ErrorAction SilentlyContinue | ForEach-Object { Remove-Ansi ([string]$_) }) + } catch { return @() } +} + +function Save-RuntimeState([string]$Status, [string]$Message, [int]$ProcessId = 0, [int]$Elapsed = 0, [string]$ErrorMessage = '') { + $state = [ordered]@{ + status = $Status + message = $Message + pid = if ($ProcessId -gt 0) { $ProcessId } else { $null } + port = $ComfyPort + url = $ComfyUrl + elapsed_seconds = $Elapsed + updated_at = (Get-Date).ToString('o') + stdout_log = $StdoutLog + stderr_log = $StderrLog + error = $ErrorMessage + stdout_tail = @(Read-Tail $StdoutLog 12) + stderr_tail = @(Read-Tail $StderrLog 16) + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 5) + if ($ProcessId -gt 0) { Write-Utf8NoBom -Path $PidFile -Content ([string]$ProcessId) } +} + + +function Test-ComfyFlag([string]$Flag) { + try { + $help = & $Python -s $Main --help 2>&1 | Out-String + return $help -match [regex]::Escape($Flag) + } catch { return $false } +} + +function Get-NvidiaVramGb { + try { + $line = & nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1 + if ($line -match '^\s*(\d+)') { return [math]::Round(([double]$Matches[1] / 1024), 1) } + } catch {} + return 0 +} + +function Get-PerformanceArguments([string]$RequestedProfile) { + $effective = $RequestedProfile + $vramGb = Get-NvidiaVramGb + if ($effective -eq 'auto') { + if ($vramGb -ge 12) { $effective = 'fast' } + elseif ($vramGb -gt 0 -and $vramGb -lt 8) { $effective = 'lowvram' } + else { $effective = 'balanced' } + } + $flags = New-Object System.Collections.Generic.List[string] + if ($effective -eq 'fast') { + $flags.Add('--highvram') + $flags.Add('--cache-classic') + if (Test-ComfyFlag '--fast') { + $flags.Add('--fast'); $flags.Add('fp16_accumulation'); $flags.Add('cublas_ops') + } + } elseif ($effective -eq 'lowvram') { + $flags.Add('--lowvram') + $flags.Add('--cache-none') + } else { + $flags.Add('--cache-classic') + } + return [pscustomobject]@{ profile = $effective; requested = $RequestedProfile; vram_gb = $vramGb; flags = @($flags) } +} + +function Test-ComfyReady { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 4 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +function Get-ComfyProcess { + try { + $items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and + $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and + $_.CommandLine -match "--port\s+$ComfyPort" + } | Select-Object -First 1) + if ($items.Count -gt 0) { return $items[0] } + return $null + } catch { return $null } +} + +try { + if (-not (Test-Path -LiteralPath $Python)) { throw "Embedded Python wurde nicht gefunden: $Python" } + if (-not (Test-Path -LiteralPath $Main)) { throw "ComfyUI main.py wurde nicht gefunden: $Main" } + + if (Test-ComfyReady) { + $existingReady = Get-ComfyProcess + $readyPid = if ($existingReady) { [int]$existingReady.ProcessId } else { 0 } + Save-RuntimeState 'ready' 'FLUX / ComfyUI ist bereits aktiv und erreichbar.' $readyPid 0 + exit 0 + } + + $existing = Get-ComfyProcess + $comfyPid = 0 + $process = $null + if ($existing) { + $comfyPid = [int]$existing.ProcessId + Save-RuntimeState 'starting' 'Ein vorhandener ComfyUI-Prozess initialisiert noch.' $comfyPid 0 + } else { + Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue + $performance = Get-PerformanceArguments $Profile + $arguments = @( + '-s', $Main, + '--listen', '127.0.0.1', + '--port', [string]$ComfyPort, + '--disable-auto-launch', + '--preview-method', 'none', + '--windows-standalone-build', + '--disable-all-custom-nodes', + '--log-stdout', + '--verbose', 'INFO' + ) + foreach ($flag in $performance.flags) { $arguments += $flag } + $process = Start-Process -FilePath $Python -ArgumentList $arguments -WorkingDirectory $PortableRoot -WindowStyle Hidden -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -PassThru + if (-not $process) { throw 'Der ComfyUI-Prozess konnte nicht gestartet werden.' } + $comfyPid = [int]$process.Id + Save-RuntimeState 'starting' ("FLUX / ComfyUI wird initialisiert · Profil: {0} · VRAM: {1} GB." -f $performance.profile, $performance.vram_gb) $comfyPid 0 + } + + $startedAt = Get-Date + while (((Get-Date) - $startedAt).TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Seconds 2 + $elapsed = [int][math]::Round(((Get-Date) - $startedAt).TotalSeconds) + + if (Test-ComfyReady) { + Save-RuntimeState 'ready' "FLUX / ComfyUI ist nach ${elapsed}s erreichbar." $comfyPid $elapsed + exit 0 + } + + $alive = $false + try { $alive = $null -ne (Get-Process -Id $comfyPid -ErrorAction SilentlyContinue) } catch {} + if (-not $alive) { + $stderrTail = (Read-Tail $StderrLog 24) -join "`n" + $stdoutTail = (Read-Tail $StdoutLog 24) -join "`n" + $detail = if ($stderrTail) { $stderrTail } elseif ($stdoutTail) { $stdoutTail } else { 'Der Prozess wurde ohne verwertbare Ausgabe beendet.' } + Save-RuntimeState 'failed' 'ComfyUI wurde während des Starts beendet.' $comfyPid $elapsed $detail + throw "ComfyUI wurde während des Starts beendet. $detail" + } + + $tail = @((Read-Tail $StdoutLog 3) + (Read-Tail $StderrLog 3)) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $lastLine = if ($tail.Count -gt 0) { [string]$tail[-1] } else { 'ComfyUI initialisiert Python, Datenbank und Modelle.' } + Save-RuntimeState 'starting' "Start läuft seit ${elapsed}s: $lastLine" $comfyPid $elapsed + } + + $timeoutDetail = @((Read-Tail $StderrLog 24) + (Read-Tail $StdoutLog 24)) -join "`n" + Stop-Process -Id $comfyPid -Force -ErrorAction SilentlyContinue + Save-RuntimeState 'failed' "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet." $comfyPid $StartupTimeoutSeconds $timeoutDetail + throw "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet. Prüfe $StderrLog und $StdoutLog." +} catch { + $message = Remove-Ansi ([string]$_.Exception.Message) + $currentPid = 0 + try { if (Test-Path -LiteralPath $PidFile) { $currentPid = [int](Get-Content -LiteralPath $PidFile -Raw).Trim() } } catch {} + $elapsed = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $oldState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $elapsed = [int]$oldState.elapsed_seconds + } + } catch {} + Save-RuntimeState 'failed' 'FLUX / ComfyUI konnte nicht gestartet werden.' $currentPid $elapsed $message + Write-Error $message + exit 1 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-stop-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-stop-local-flux.ps1 new file mode 100644 index 0000000..73ffc6a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/runtime-stop-local-flux.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'SilentlyContinue' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LogDir = Join-Path $VendooDir 'logs' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +$items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and $_.CommandLine -match '--port\s+8188' +}) +foreach ($item in $items) { Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue } +Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$state = [ordered]@{ + status = 'stopped' + message = 'FLUX / ComfyUI wurde beendet.' + pid = $null + port = 8188 + url = 'http://127.0.0.1:8188' + elapsed_seconds = 0 + updated_at = (Get-Date).ToString('o') + error = '' +} +Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) +exit 0 diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/start-local-flux.bat b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/start-local-flux.bat new file mode 100644 index 0000000..8a2afe7 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/start-local-flux.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0\.." +powershell -ExecutionPolicy Bypass -File "%~dp0\..\local-ai\start-local-flux.ps1" +pause diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/uninstall-local-flux.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/uninstall-local-flux.ps1 new file mode 100644 index 0000000..a42d1ef --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/scripts/uninstall-local-flux.ps1 @@ -0,0 +1,114 @@ +param( + [switch]$NonInteractive, + [switch]$RemoveDownloads +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$ComfyRoot = Join-Path $LocalAiDir 'comfyui' +$ExtractTemp = Join-Path $LocalAiDir 'extract-temp' +$Downloads = Join-Path $LocalAiDir 'downloads' +$StateFile = Join-Path $LocalAiDir 'install-state.json' +$StartScript = Join-Path $LocalAiDir 'start-local-flux.ps1' +$StopScript = Join-Path $LocalAiDir 'stop-local-flux.ps1' +$TaskName = 'Vendoo Local FLUX' +$LogDir = Join-Path $VendooDir 'logs' +$LogFile = Join-Path $LogDir 'local-flux-uninstall.log' + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +New-Item -ItemType Directory -Force -Path $LocalAiDir | Out-Null + +function Write-Log([string]$Message, [string]$Level = 'INFO') { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 +} + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +try { + Write-Log 'Deinstallation von lokalem FLUX wurde gestartet.' + + if (Test-Path -LiteralPath $StopScript) { + try { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $StopScript | Out-Null + Write-Log 'Vorhandenes Stoppskript wurde ausgeführt.' 'OK' + } catch { + Write-Log "Stoppskript meldete: $($_.Exception.Message)" 'WARN' + } + } + + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -match 'ComfyUI\\main.py' -or + ($_.CommandLine -match [regex]::Escape($VendooDir) -and $_.CommandLine -match 'python_embeded\\python.exe') + }) + foreach ($process in $processes) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + if ($processes.Count -gt 0) { Write-Log "$($processes.Count) ComfyUI-Prozess(e) beendet." 'OK' } + } catch { + Write-Log "Prozessbereinigung meldete: $($_.Exception.Message)" 'WARN' + } + + Start-Sleep -Seconds 2 + & schtasks.exe /Delete /TN $TaskName /F 2>$null | Out-Null + Write-Log 'Autostart-Aufgabe wurde entfernt oder war nicht vorhanden.' 'OK' + + if (Test-Path -LiteralPath $ComfyRoot) { + Remove-Item -LiteralPath $ComfyRoot -Recurse -Force + Write-Log 'ComfyUI und FLUX-Modell wurden entfernt.' 'OK' + } + if (Test-Path -LiteralPath $ExtractTemp) { + Remove-Item -LiteralPath $ExtractTemp -Recurse -Force + Write-Log 'Temporäre Entpackdaten wurden entfernt.' 'OK' + } + foreach ($path in @($StartScript, $StopScript)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } + } + if ($RemoveDownloads -and (Test-Path -LiteralPath $Downloads)) { + Remove-Item -LiteralPath $Downloads -Recurse -Force + Write-Log 'Installationsarchive und Teildownloads wurden entfernt.' 'OK' + } else { + Write-Log 'Installationsarchive bleiben für eine schnelle Neuinstallation erhalten.' 'INFO' + } + + $state = [ordered]@{ + status = 'not-started' + phase = 'uninstalled' + progress = 0 + message = 'Lokales FLUX wurde deinstalliert.' + started_at = $null + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + elapsed_seconds = 0 + can_resume = $false + model_installed = $false + comfy_installed = $false + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) + Write-Log 'Deinstallation erfolgreich abgeschlossen.' 'OK' + exit 0 +} catch { + Write-Log $_.Exception.Message 'ERROR' + try { + $failed = [ordered]@{ + status = 'failed' + phase = 'error' + progress = 0 + message = "Deinstallation fehlgeschlagen: $($_.Exception.Message)" + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + } + Write-Utf8NoBom -Path $StateFile -Content ($failed | ConvertTo-Json -Depth 4) + } catch {} + exit 1 +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/server.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/server.mjs new file mode 100644 index 0000000..43302b9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/server.mjs @@ -0,0 +1,3508 @@ +import express from 'express'; +import multer from 'multer'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import sharp from 'sharp'; +import { join, dirname, extname, basename, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID, randomBytes, createHash, timingSafeEqual } from 'crypto'; +import { networkInterfaces } from 'os'; +import { spawn } from 'child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync, readdirSync, copyFileSync } from 'fs'; +import { CONFIG_PATH, readRuntimeConfig, setConfigValue, writeRuntimeConfig, updateRuntimeConfig } from './lib/config-store.mjs'; +import { createPlatformKernel } from './app/kernel/platform-kernel.mjs'; +import { getThemeContract } from './app/core/themes/theme-contract.mjs'; +import { getThemeService } from './app/modules/themes/index.mjs'; +import { + createUser, getUser, getUserByEmail, getUsers, updateUser, deleteUser, + createSession, validateSession, destroySession, destroySessionById, destroyUserSessions, + getUserSessions, getAllActiveSessions, + createAuthToken, validateAuthToken, + checkRateLimit, generateCsrfToken, + auditLog, getAuditLog, searchAuditLog, getAuditLogForUser, + recordLogin, getLoginHistory, + isSetupMode, getUserCount, + verifyPassword, setPassword, validatePasswordStrength, registerFailedLogin, clearFailedLogins, isUserLocked, + cleanExpiredSessions, +} from './lib/auth.mjs'; +import { initMailer, sendMagicLink, sendCredentials, isMailerConfigured, reinitMailer, getMailerConfig, testMailer } from './lib/mailer.mjs'; +import { + createListing, getListing, getListings, updateListing, deleteListing, + softDeleteListing, restoreListing, getDeletedListings, permanentlyDeleteListing, cleanupTrash, getTrashCount, + duplicateListing, + getSettings, updateSettings, + createTemplate, getTemplate, getTemplates, updateTemplate, deleteTemplate, + getListingsForExport, + createPublishLog, updatePublishLog, getPublishLogs, getPublishStatus, + addToEbayQueue, getEbayQueue, updateEbayQueue, removeFromEbayQueue, clearEbayQueue, + schedulePublish, getScheduledPublishes, getDueScheduled, updateScheduledPublish, removeScheduledPublish, + getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation, + getDashboardWorkflow, getDashboardAnalytics, + createMobileUploadSession, getMobileUploadSessionById, getMobileUploadSessionByTokenHash, + appendMobileUploadFiles, updateMobileUploadSession, deleteMobileUploadSession, cleanupMobileUploadSessions, + createAiModelGeneration, updateAiModelGeneration, createAiModelPhoto, getAiModelPhotosForListing, + getAiModelGenerationsForListing, getAiModelPhotosForGeneration, getAiModelJob, + createFluxImage, getFluxImages, paginateFluxImages, getFluxImage, setFluxImageFavorite, deleteFluxImage, + getImageEditVersions, getImageEditRoot, createImageEditVersion, + listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords, db, +} from './lib/db.mjs'; +import * as etsyApi from './lib/etsy-api.mjs'; +import * as ebayApi from './lib/ebay-api.mjs'; +import { wrapInTemplate, getHtmlTemplates, sanitizeRichHtml } from './lib/html-templates.mjs'; +import { prepareForAI, removeBackground, addWatermark } from './lib/images.mjs'; +import { generate, generateText, getProviders } from './lib/ai-router.mjs'; +import { calculateFees, getAllFees } from './lib/fees.mjs'; +import { createZip } from './lib/zip.mjs'; +import { + createBackup, listBackups, getBackupPath, verifyStoredBackup, deleteBackup, stageRestore, + stageUpdate, getOperationsStatus, cancelPendingOperation, checkForUpdates, + updateOperationSettings, recordExtensionHeartbeat, getExtensionDiagnostics, + startAutomaticBackupLoop, operationsPaths, +} from './lib/operations-center.mjs'; +import { + analyzeListingQuality, listQualityCenter, getQualityHistory, compareQualityReports, +} from './lib/quality-center.mjs'; +import { getCategories, getCategoriesFlat, matchCategories, getAllPlatformIds } from './lib/categories.mjs'; +import { precheckAiModelPhotos, generateAiModelPhotos, fetchOpenRouterImageModels, getOpenRouterImageDiagnostics } from './lib/ai-model-photos.mjs'; +import { getLocalImageStatus, getLocalFluxRuntimeInfo, generateLocalImageToken, ensureLocalFluxBootstrap } from './lib/ai-local-images.mjs'; +import { renderImageVersion, resolveEditableImage } from './lib/image-editor.mjs'; +import { enqueueAiModelJob, getAiModelJobWithRelations, startAiModelJobLoop } from './lib/ai-model-jobs.mjs'; +import { + enqueueFluxPromptJob, getFluxPromptJob, listFluxPromptJobs, paginateFluxPromptJobs, getFluxPromptJobSummary, + cancelFluxPromptJob, retryFluxPromptJob, clearFluxPromptQueue, archiveFluxPromptJobs, restoreFluxPromptJobs, + deleteArchivedFluxPromptJobs, startFluxPromptJobLoop, +} from './lib/ai-flux-prompt-jobs.mjs'; +import { + listImageBatchProfiles, createImageBatchProfile, updateImageBatchProfile, deleteImageBatchProfile, + createImageBatchJob, getImageBatchJob, paginateImageBatchJobs, pauseImageBatchJob, resumeImageBatchJob, + cancelImageBatchJob, retryImageBatchJob, retryImageBatchItem, deleteImageBatchJob, getImageBatchOutputPaths, + startImageBatchJobLoop, +} from './lib/image-batch-jobs.mjs'; +import { + enqueuePublishingJob, getPublishingJob, paginatePublishingJobs, listPublishingEvents, + retryPublishingJob, cancelPublishingJob, deletePublishingJobs, claimExtensionJob, completeExtensionJob, + configurePublishingProcessors, startPublishingJobLoop, recoverPublishingJobs, migrateLegacyEbayQueue, getPublishCapabilities, +} from './lib/publishing-jobs.mjs'; +import { + getRoleDefinitions, getPermissionCatalog, permissionsForRole, hasPermission, requiredPermissionForApi, + acquireResourceLock, getResourceLock, listResourceLocks, renewResourceLock, releaseResourceLock, + validateResourceLock, consumeRateLimit, getRateLimitStats, +} from './lib/security.mjs'; +import { + APP_VERSION, DB_PATH, UPLOADS_DIR, OPERATIONS_DIR, LOG_DIR, + ensureRuntimeDirectories, verifyRuntimeWritable, runtimePathSummary, +} from './lib/runtime-paths.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = express(); +const PORT = Number(process.env.PORT || 8124); +ensureRuntimeDirectories(); +let shuttingDown = false; + +const platformKernel = createPlatformKernel({ version: APP_VERSION, hasPermission }); +await platformKernel.start(); + +startAiModelJobLoop(); +startFluxPromptJobLoop(); +startImageBatchJobLoop(); +startAutomaticBackupLoop(); +const legacyPublishingMigration = migrateLegacyEbayQueue(); +if (legacyPublishingMigration.migrated) console.log(`Publishing: ${legacyPublishingMigration.migrated} alte eBay-Queue-Einträge übernommen`); +recoverPublishingJobs(); +configurePublishingProcessors({ + async process(job, listing) { + const payload = job.payload || {}; + const previous = db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND id != ? + AND status IN ('published','ended') ORDER BY completed_at DESC, created_at DESC LIMIT 1`).get(job.listing_id, job.platform, job.id); + const offerId = job.offer_id || payload.offerId || previous?.offer_id || null; + const externalId = job.external_id || payload.externalId || previous?.external_id || null; + + if (job.platform === 'ebay-de') { + if (!ebayApi.isConnected()) throw new Error('eBay ist nicht verbunden'); + const sku = listing.sku || `AL-${listing.id}`; + if (job.action === 'publish') return ebayApi.createAndPublish(listing, sku); + if (job.action === 'update') { + if (!offerId) throw new Error('Keine eBay Offer-ID für die Aktualisierung vorhanden'); + const offer = await ebayApi.updateOffer(listing, offerId, sku); + return { offerId, externalId: offer?.listing?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'published', offer }; + } + if (job.action === 'end') { + if (!offerId) throw new Error('Keine eBay Offer-ID zum Beenden vorhanden'); + const result = await ebayApi.withdrawOffer(offerId); + return { offerId, externalId: result?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'ended', result }; + } + if (job.action === 'sync') { + if (!offerId) throw new Error('Keine eBay Offer-ID für den Statusabgleich vorhanden'); + const offer = await ebayApi.getOffer(offerId); + const status = String(offer?.status || '').toUpperCase() === 'PUBLISHED' ? 'published' : 'ended'; + const listingId = offer?.listing?.listingId || externalId; + return { offerId, externalId: listingId, url: listingId ? `https://www.ebay.de/itm/${listingId}` : null, status, offer }; + } + } + + if (job.platform === 'etsy') { + if (!etsyApi.isConnected()) throw new Error('Etsy ist nicht verbunden'); + if (job.action === 'publish') { + const shopId = await etsyApi.getShopId(); + const created = await etsyApi.createEtsyListing(listing, shopId); + if (listing.photos?.length) { + for (const photo of listing.photos) { + const photoPath = join(UPLOADS_DIR, photo); + if (!existsSync(photoPath)) continue; + await etsyApi.uploadEtsyImage(shopId, created.listing_id, readFileSync(photoPath), photo); + } + } + return { externalId: String(created.listing_id), url: `https://www.etsy.com/listing/${created.listing_id}`, status: created.state || 'draft', listing: created }; + } + if (job.action === 'sync') { + if (!externalId) throw new Error('Keine Etsy Listing-ID für den Statusabgleich vorhanden'); + const item = await etsyApi.getEtsyListing(externalId); + const status = ['active', 'draft'].includes(item?.state) ? 'published' : 'ended'; + return { externalId: String(externalId), url: `https://www.etsy.com/listing/${externalId}`, status, listing: item }; + } + throw new Error(`Aktion ${job.action} wird für Etsy derzeit nicht unterstützt`); + } + throw new Error(`Keine offizielle API-Verarbeitung für ${job.platform}`); + }, +}); +startPublishingJobLoop(); + + +function plainTextToSafeHtml(text) { + const escaped = String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return escaped.split(/\n{2,}/).map(part => `

                          ${part.replace(/\n/g, '
                          ')}

                          `).join(''); +} + +function htmlToPlainText(html) { + return String(html || '') + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function normalizeListingPayload(payload = {}) { + const normalized = { ...payload }; + if (normalized.description_html !== undefined) { + normalized.description_html = sanitizeRichHtml(normalized.description_html); + if (normalized.description === undefined) normalized.description = htmlToPlainText(normalized.description_html); + } + if (normalized.description !== undefined) normalized.description = String(normalized.description || '').trim(); + if (normalized.tags !== undefined && !Array.isArray(normalized.tags)) { + normalized.tags = String(normalized.tags || '').split(',').map(tag => tag.trim()).filter(Boolean); + } + if (normalized.photos !== undefined && !Array.isArray(normalized.photos)) normalized.photos = []; + return normalized; +} + + +function hashMobileUploadToken(token) { + return createHash('sha256').update(String(token || '')).digest('hex'); +} + +function isMobileUploadSessionUsable(session) { + if (!session || session.status !== 'active') return false; + const expiresAt = Date.parse(String(session.expires_at || '').replace(' ', 'T') + 'Z'); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +} + +function getLanIpv4Addresses() { + const addresses = []; + const interfaces = networkInterfaces(); + for (const [name, entries] of Object.entries(interfaces)) { + for (const entry of entries || []) { + const family = typeof entry.family === 'string' ? entry.family : (entry.family === 4 ? 'IPv4' : String(entry.family)); + if (family !== 'IPv4' || entry.internal) continue; + const address = String(entry.address || '').trim(); + if (!address || address.startsWith('169.254.')) continue; + const isPrivate = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(address); + const lowerName = String(name || '').toLowerCase(); + const isVirtual = /(virtual|vmware|hyper-v|vethernet|wsl|docker|vpn|tailscale|zerotier|loopback|bluetooth)/i.test(lowerName); + const isPrimaryAdapter = /(ethernet|wlan|wi-fi|wifi|lan)/i.test(lowerName); + const score = (isPrivate ? 100 : 0) + (isPrimaryAdapter ? 30 : 0) - (isVirtual ? 80 : 0); + addresses.push({ name, address, isPrivate, isVirtual, score }); + } + } + addresses.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + return addresses; +} + +function resolvePublicBaseUrl(req) { + const configured = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, ''); + if (configured) { + try { + const configuredUrl = new URL(configured); + const configuredHost = configuredUrl.hostname.toLowerCase(); + if (!['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(configuredHost)) return configured; + } catch {} + } + + const forwardedProto = String(req.get('x-forwarded-proto') || '').split(',')[0].trim(); + const protocol = forwardedProto || req.protocol || 'http'; + const rawHost = String(req.get('x-forwarded-host') || req.get('host') || `localhost:${PORT}`).split(',')[0].trim(); + const hostOnly = rawHost.replace(/^\[/, '').replace(/\](:\d+)?$/, '').replace(/:\d+$/, '').toLowerCase(); + const isLoopback = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostOnly); + if (!isLoopback) return `${protocol}://${rawHost}`.replace(/\/$/, ''); + + const lan = getLanIpv4Addresses()[0]; + const portMatch = rawHost.match(/:(\d+)$/); + const port = portMatch?.[1] || String(PORT); + if (lan?.address) return `${protocol}://${lan.address}:${port}`; + return `${protocol}://${rawHost}`.replace(/\/$/, ''); +} + +function parseTrustProxy(value) { + const raw = String(value || 'false').trim().toLowerCase(); + if (['false', '0', 'no', 'off', ''].includes(raw)) return false; + if (['true', 'yes', 'on'].includes(raw)) return 1; + if (/^\d+$/.test(raw)) return Math.max(0, Number(raw)); + if (['loopback', 'linklocal', 'uniquelocal'].includes(raw)) return raw; + throw new Error('VENDOO_TRUST_PROXY muss false, true, eine Hop-Anzahl oder loopback/linklocal/uniquelocal sein.'); +} +const TRUST_PROXY = parseTrustProxy(process.env.VENDOO_TRUST_PROXY); +if (TRUST_PROXY !== false) app.set('trust proxy', TRUST_PROXY); + +const SECURITY_CSP = { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'", 'http://127.0.0.1:*', 'http://localhost:*'], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], +}; +app.use(helmet({ + contentSecurityPolicy: { directives: SECURITY_CSP }, + crossOriginEmbedderPolicy: false, + referrerPolicy: { policy: 'same-origin' }, +})); +app.use(express.json({ limit: '2mb' })); +app.use(cookieParser()); +app.use((req, res, next) => { + const requestId = String(req.get('x-vendoo-request-id') || randomUUID()).slice(0, 100); + req.requestId = requestId; + res.setHeader('X-Vendoo-Request-ID', requestId); + res.setHeader('Cache-Control', req.path.startsWith('/api/') || req.path.startsWith('/auth/') ? 'no-store' : 'private, max-age=0, must-revalidate'); + next(); +}); + +function configuredAllowedOrigins() { + return new Set(String(process.env.VENDOO_ALLOWED_ORIGINS || '') + .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); +} + +function configuredExtensionOrigins() { + return new Set(String(process.env.VENDOO_EXTENSION_ORIGINS || '') + .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); +} + +function isAllowedOrigin(req, origin) { + if (!origin) return true; + const normalized = origin.replace(/\/$/, ''); + if (/^(chrome-extension|moz-extension|safari-web-extension):\/\//i.test(normalized)) { + const allowAny = /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || (process.env.NODE_ENV === 'production' ? 'false' : 'true'))); + return allowAny || configuredExtensionOrigins().has(normalized); + } + const ownOrigin = `${req.protocol}://${req.get('host')}`.replace(/\/$/, ''); + if (normalized === ownOrigin) return true; + return configuredAllowedOrigins().has(normalized); +} + +function isAllowedHost(hostHeader) { + const raw = String(hostHeader || '').trim(); + let host = raw; + if (raw.startsWith('[')) host = raw.slice(1, raw.indexOf(']') > 0 ? raw.indexOf(']') : undefined); + else if ((raw.match(/:/g) || []).length === 1) host = raw.replace(/:\d+$/, ''); + host = host.toLowerCase(); + if (!host) return false; + const configured = new Set(String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean)); + if (configured.has(host)) return true; + if (['localhost', '127.0.0.1', '::1'].includes(host)) return true; + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return true; + try { + const publicHost = new URL(String(process.env.PUBLIC_BASE_URL || '')).hostname.toLowerCase(); + if (publicHost && host === publicHost) return true; + } catch {} + return configured.size === 0 && !/^[a-z0-9.-]+$/i.test(host) ? false : configured.has(host); +} + +app.use((req, res, next) => { + if (!isAllowedHost(req.get('host'))) return res.status(421).json({ error: 'Host nicht freigegeben. VENDOO_ALLOWED_HOSTS prüfen.' }); + const origin = req.get('origin'); + if (origin && !isAllowedOrigin(req, origin)) return res.status(403).json({ error: 'Origin nicht freigegeben.' }); + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Token,X-Vendoo-Request-ID,X-Edit-Lock'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +app.get('/healthz', (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.json({ ok: true, version: APP_VERSION, uptime_seconds: Math.floor(process.uptime()), time: new Date().toISOString() }); +}); + +app.get('/readyz', async (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const checks = []; + try { + const dbCheck = db.prepare('SELECT 1 AS ok').get(); + checks.push({ name: 'database', ok: Number(dbCheck?.ok) === 1, path: DB_PATH }); + } catch (error) { + checks.push({ name: 'database', ok: false, path: DB_PATH, error: error.message }); + } + for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, ...item }); + try { + const kernel = await platformKernel.health(); + checks.push({ name: 'platform-kernel', ok: kernel.ok, lifecycle: kernel.lifecycle.state, modules: kernel.modules.length }); + } catch (error) { + checks.push({ name: 'platform-kernel', ok: false, error: error.message }); + } + const ok = !shuttingDown && checks.every(item => item.ok !== false); + res.status(ok ? 200 : 503).json({ ok, shutting_down: shuttingDown, version: APP_VERSION, checks, paths: runtimePathSummary(), time: new Date().toISOString() }); +}); + +// Init mailer — check DB settings first, fallback to .env +function initMailerFromDbOrEnv() { + try { + const settings = getSettings(); + if (settings.smtp_host && settings.smtp_user && settings.smtp_pass) { + const ok = reinitMailer({ + host: settings.smtp_host, + port: settings.smtp_port || '587', + user: settings.smtp_user, + pass: settings.smtp_pass, + }); + if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from; + if (ok) return; + } + } catch {} + initMailer(); +} +initMailerFromDbOrEnv(); + +// --- Auth Middleware --- +function getClientIp(req) { + return TRUST_PROXY ? String(req.ip || '') : String(req.socket?.remoteAddress || ''); +} + +function requireAuth(req, res, next) { + if (isSetupMode()) { + if (req.originalUrl.startsWith('/api')) return res.status(503).json({ error: 'Vendoo muss zuerst über /login.html eingerichtet werden.', setup_required: true }); + return res.redirect('/login.html'); + } + + const token = req.cookies?.auth_token; + const session = validateSession(token); + if (!session) { + if (req.originalUrl.startsWith('/api')) return res.status(401).json({ error: 'Nicht eingeloggt' }); + return res.redirect('/login.html'); + } + req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, avatar_color: session.avatar_color, permissions: [...permissionsForRole(session.role)] }; + next(); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Keine Berechtigung' }); + next(); + }; +} + +function csrfProtect(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const token = req.headers['x-csrf-token'] || req.body?._csrf; + const cookie = req.cookies?.csrf_token; + if (!token || !cookie || token !== cookie) { + return res.status(403).json({ error: 'Ungültiger CSRF-Token' }); + } + next(); +} + +function requirePermission(permission) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + if (!hasPermission(req.user.role, permission)) { + auditLog(req.user.id, req.user.email, 'security.permission_denied', 'route', req.path, JSON.stringify({ permission, method: req.method }), getClientIp(req)); + return res.status(403).json({ error: 'Diese Aktion ist für deine Rolle nicht freigegeben.', permission }); + } + next(); + }; +} + +function apiPermissionGuard(req, res, next) { + const routePath = String(req.originalUrl || req.path || '').split('?')[0]; + const permission = requiredPermissionForApi(req.method, routePath); + if (!permission || hasPermission(req.user?.role, permission)) return next(); + auditLog(req.user?.id || null, req.user?.email || null, 'security.permission_denied', 'route', routePath, JSON.stringify({ permission, method: req.method }), getClientIp(req)); + return res.status(403).json({ error: 'Keine Berechtigung für diese Aktion.', permission }); +} + +function apiRateLimit(req, res, next) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(req.method); + const limit = mutation ? Number(process.env.VENDOO_MUTATION_RATE_LIMIT || 120) : Number(process.env.VENDOO_READ_RATE_LIMIT || 360); + const key = `api:${req.user?.id || getClientIp(req)}:${mutation ? 'write' : 'read'}`; + const result = consumeRateLimit(key, { limit: Math.max(20, limit), windowMs: 60_000 }); + res.setHeader('X-RateLimit-Remaining', String(result.remaining)); + if (!result.allowed) { + res.setHeader('Retry-After', String(result.retryAfter)); + return res.status(429).json({ error: 'Zu viele Anfragen. Bitte kurz warten.', retry_after: result.retryAfter }); + } + next(); +} + +function secureCookieOptions(req) { + const forwardedHttps = TRUST_PROXY !== false && String(req.get('x-forwarded-proto') || '').split(',')[0].trim() === 'https'; + const secureMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase(); + const detectedSecure = Boolean(req.secure || forwardedHttps || /^https:\/\//i.test(String(process.env.PUBLIC_BASE_URL || ''))); + const secure = secureMode === 'true' ? true : secureMode === 'false' ? false : detectedSecure; + const sameSiteValue = String(process.env.VENDOO_COOKIE_SAMESITE || 'lax').trim().toLowerCase(); + const sameSite = ['lax', 'strict', 'none'].includes(sameSiteValue) ? sameSiteValue : 'lax'; + if (sameSite === 'none' && !secure) throw new Error('VENDOO_COOKIE_SAMESITE=none erfordert sichere HTTPS-Cookies.'); + const maxAge = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000; + return { httpOnly: true, secure, sameSite, maxAge, path: '/' }; +} + +function csrfCookieOptions(req) { + return { ...secureCookieOptions(req), httpOnly: false }; +} + +const SETUP_TOKEN = String(process.env.VENDOO_SETUP_TOKEN || '').trim(); +function setupTokenMatches(value) { + if (!SETUP_TOKEN) return true; + const provided = Buffer.from(String(value || '')); + const expected = Buffer.from(SETUP_TOKEN); + return provided.length === expected.length && timingSafeEqual(provided, expected); +} + +// --- Public routes (no auth) --- +app.use('/login.html', express.static(join(__dirname, 'public', 'login.html'))); + +app.get('/auth/setup-check', (_req, res) => { + res.json({ setupMode: isSetupMode(), mailConfigured: isMailerConfigured(), setupTokenRequired: Boolean(SETUP_TOKEN) }); +}); + +// Setup: create first admin with password +app.post('/auth/setup', (req, res) => { + if (!isSetupMode()) return res.status(400).json({ error: 'Setup bereits abgeschlossen' }); + const { email, name, password, setup_token: setupToken } = req.body; + if (!setupTokenMatches(req.get('x-vendoo-setup-token') || setupToken)) return res.status(403).json({ error: 'Ungültiger Einrichtungs-Code.' }); + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + + const ip = getClientIp(req); + try { + const user = createUser(email, name || email.split('@')[0], 'admin', null, password); + auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via setup', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + return res.json({ ok: true, redirect: '/' }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Login with email + password +app.post('/auth/login', (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' }); + + const ip = getClientIp(req); + const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000); + if (!rl.allowed) return res.status(429).json({ error: `Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.` }); + + const user = getUserByEmail(email); + if (user && isUserLocked(user)) { + auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip); + return res.status(423).json({ error: 'Account nach mehreren Fehlversuchen vorübergehend gesperrt.', locked_until: user.locked_until }); + } + if (!user || !verifyPassword(password, user.password_hash)) { + if (user) { + recordLogin(user.id, ip, req.headers['user-agent'] || '', false); + const lockResult = registerFailedLogin(user.id); + if (lockResult?.locked) { + auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip); + return res.status(423).json({ error: 'Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', locked_until: lockResult.lockedUntil }); + } + } + auditLog(user?.id || null, email, 'login.failed', null, null, null, ip); + return res.status(401).json({ error: 'E-Mail oder Passwort falsch' }); + } + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + clearFailedLogins(user.id); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via password', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + res.json({ ok: true, redirect: '/' }); +}); + +// Invite: admin sends invite link (magic link only for invitations) +app.post('/auth/invite', requireAuth, csrfProtect, requireRole('admin'), async (req, res) => { + const { email, name, role } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const baseUrl = `${req.protocol}://${req.get('host')}`; + try { + const { token } = createAuthToken(email, 'invite', role || 'editor', req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${role || 'editor'}`, getClientIp(req)); + res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Magic link: only for invite — shows password-set form +app.get('/auth/magic', (req, res) => { + const { token } = req.query; + if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben')); + res.redirect(`/login.html?invite=${encodeURIComponent(token)}`); +}); + +// Accept invite: set password and activate account +app.post('/auth/accept-invite', (req, res) => { + const { token, password, name } = req.body; + if (!token) return res.status(400).json({ error: 'Kein Token angegeben' }); + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + + try { + const authToken = validateAuthToken(token); + if (!authToken) return res.status(400).json({ error: 'Link ungültig oder abgelaufen' }); + + const ip = getClientIp(req); + const ua = req.headers['user-agent'] || ''; + + let user = getUserByEmail(authToken.email); + if (!user) { + user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password); + auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip); + } else { + setPassword(user.id, password); + if (name) updateUser(user.id, { name }); + } + + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + + const session = createSession(user.id, ip, ua); + recordLogin(user.id, ip, ua, true); + auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + res.json({ ok: true, redirect: '/' }); + } catch (err) { + console.error('Accept invite error:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/auth/logout', requireAuth, csrfProtect, (req, res) => { + const token = req.cookies?.auth_token; + if (token) { + const session = validateSession(token); + if (session) { + auditLog(session.user_id, session.email, 'logout', null, null, null, getClientIp(req)); + } + destroySession(token); + } + res.clearCookie('auth_token'); + res.clearCookie('csrf_token'); + res.json({ ok: true }); +}); + + +// --- Mobile photo upload (public token page, no account login required) --- +app.get('/mobile-upload/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).sendFile(join(__dirname, 'public', 'mobile-upload-expired.html')); + res.sendFile(join(__dirname, 'public', 'mobile-upload.html')); +}); + +app.get('/mobile-upload-api/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + res.json({ + id: session.id, + context: session.context, + listing_id: session.listing_id, + files: session.files || [], + remaining: Math.max(0, 10 - (session.files || []).length), + expires_at: session.expires_at, + }); +}); + +// --- Protected routes below --- +app.use('/api', requireAuth); +app.use('/api', apiRateLimit); +app.use('/api', apiPermissionGuard); +app.use('/auth/ebay', requireAuth, requirePermission('settings.manage')); +app.use('/auth/etsy', requireAuth, requirePermission('settings.manage')); + +// CSRF for state-changing API calls +app.use('/api', csrfProtect); + +// Platform kernel contracts (new routes must be deny-by-default and explicitly owned) +platformKernel.routes.register(app, { + id: 'platform.status.read', + method: 'GET', + path: '/api/security/platform', + owner: 'vendoo.system', + policy: 'platform.security-admin', + audit: 'platform.status.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json(platformKernel.snapshot()), +}); + +platformKernel.routes.register(app, { + id: 'platform.modules.read', + method: 'GET', + path: '/api/security/platform/modules', + owner: 'vendoo.system', + policy: 'platform.security-admin', + audit: 'platform.modules.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json({ modules: platformKernel.modules.snapshot() }), +}); + +platformKernel.routes.register(app, { + id: 'themes.contract.read', + method: 'GET', + path: '/api/security/platform/theme-contract', + owner: 'vendoo.themes', + policy: 'platform.security-admin', + audit: 'themes.contract.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json(getThemeContract()), +}); + +platformKernel.routes.register(app, { + id: 'themes.catalog.read', method: 'GET', path: '/api/themes', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.catalog.read', rateLimit: 'default', csrf: false, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().catalog(req.user.id)), +}); + +platformKernel.routes.register(app, { + id: 'themes.validate', method: 'POST', path: '/api/themes/validate', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.validated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().validate(req.body || {})), +}); + +platformKernel.routes.register(app, { + id: 'themes.preference.update', method: 'PUT', path: '/api/themes/preference', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.preference.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const preference = getThemeService().saveUserPreference(req.user.id, req.body || {}); + auditLog(req.user.id, req.user.email, 'theme.preference.updated', 'user', String(req.user.id), JSON.stringify(preference), getClientIp(req)); + res.json(preference); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.create', method: 'POST', path: '/api/themes', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.created', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const theme = getThemeService().create(req.body || {}, req.user.id); + auditLog(req.user.id, req.user.email, 'theme.created', 'theme', theme.id, theme.name, getClientIp(req)); + res.status(201).json(theme); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.system-default.update', method: 'PUT', path: '/api/themes/system-default', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.system-default.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const preference = getThemeService().saveSystemDefault(req.body || {}); + auditLog(req.user.id, req.user.email, 'theme.system_default.updated', 'system', 'theme', JSON.stringify(preference), getClientIp(req)); + res.json(preference); + }, +}); + + + +platformKernel.routes.register(app, { + id: 'themes.profile.update', method: 'PUT', path: '/api/themes/:id', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const theme = getThemeService().update(req.params.id, req.body || {}, req.user.id); + auditLog(req.user.id, req.user.email, 'theme.updated', 'theme', theme.id, theme.name, getClientIp(req)); + res.json(theme); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.delete', method: 'DELETE', path: '/api/themes/:id', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.deleted', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const result = getThemeService().remove(req.params.id); + auditLog(req.user.id, req.user.email, 'theme.deleted', 'theme', req.params.id, null, getClientIp(req)); + res.json(result); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.export', method: 'GET', path: '/api/themes/:id/export', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.exported', rateLimit: 'administration', csrf: false, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().exportTheme(req.params.id)), +}); + +// Current user info +app.get('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ setupMode: true, role: 'admin' }); + const user = getUser(req.user.id); + const initials = (user.name || user.email).split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); + res.json({ + id: user.id, email: user.email, name: user.name, role: user.role, + avatar_color: user.avatar_color, initials, permissions: [...permissionsForRole(user.role)], + role_definition: getRoleDefinitions().find(role => role.key === user.role) || null, + csrf: req.cookies?.csrf_token, + }); +}); + +app.put('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + const { name } = req.body; + if (name !== undefined) updateUser(req.user.id, { name }); + auditLog(req.user.id, req.user.email, 'user.profile_updated', 'user', String(req.user.id), null, getClientIp(req)); + res.json(getUser(req.user.id)); +}); + +// --- Roles, permissions and server security --- +app.get('/api/security/roles', (_req, res) => { + res.json({ roles: getRoleDefinitions(), permissions: getPermissionCatalog() }); +}); + +app.get('/api/security/status', requirePermission('security.manage'), (req, res) => { + const bindHost = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; + const publicBaseUrl = String(process.env.PUBLIC_BASE_URL || '').trim(); + const remoteBinding = ['0.0.0.0', '::'].includes(bindHost); + const publicHttps = /^https:\/\//i.test(publicBaseUrl); + const warnings = []; + if (remoteBinding && !publicHttps) warnings.push('Vendoo lauscht im Netzwerk, aber PUBLIC_BASE_URL verwendet kein HTTPS. Für öffentlichen Zugriff ist ein HTTPS-Reverse-Proxy zwingend.'); + if (remoteBinding && !String(process.env.VENDOO_ALLOWED_HOSTS || '').trim()) warnings.push('VENDOO_ALLOWED_HOSTS ist nicht gesetzt. Private IP-Adressen bleiben erlaubt, öffentliche Hostnamen nicht.'); + if (TRUST_PROXY && !publicHttps) warnings.push('VENDOO_TRUST_PROXY ist aktiv, obwohl keine öffentliche HTTPS-Basis-URL erkannt wurde.'); + if (String(process.env.VENDOO_ALLOWED_ORIGINS || '').includes('*')) warnings.push('Wildcard-Origin ist unsicher und wird von Vendoo nicht als vertrauenswürdig behandelt.'); + if (process.env.NODE_ENV === 'production' && /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || 'false'))) warnings.push('Alle Browser-Erweiterungs-Origins sind freigegeben. Für Produktion VENDOO_EXTENSION_ORIGINS auf konkrete Extension-IDs begrenzen.'); + res.json({ + version: APP_VERSION, bind_host: bindHost, port: Number(PORT), trust_proxy: TRUST_PROXY, + public_base_url: publicBaseUrl || null, + allowed_hosts: String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(v => v.trim()).filter(Boolean), + allowed_origins: [...configuredAllowedOrigins()], + extension_origins: [...configuredExtensionOrigins()], + session_days: Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)), + session_idle_minutes: Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)), + remote_binding: remoteBinding, secure_public_url: publicHttps, + active_locks: listResourceLocks(), rate_limits: getRateLimitStats(), warnings, + request_ip: getClientIp(req), request_secure: Boolean(req.secure || (TRUST_PROXY && req.get('x-forwarded-proto') === 'https')), + }); +}); + +app.get('/api/locks/:type/:id', (req, res) => { + const lock = getResourceLock(req.params.type, req.params.id); + res.json({ lock, owned_by_me: Boolean(lock && Number(lock.user_id) === Number(req.user.id)) }); +}); + +app.post('/api/locks/:type/:id/acquire', (req, res) => { + try { + const result = acquireResourceLock({ + resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, + clientLabel: req.body?.client_label || req.get('user-agent') || '', ttlSeconds: req.body?.ttl_seconds || 120, + }); + auditLog(req.user.id, req.user.email, 'lock.acquired', req.params.type, req.params.id, null, getClientIp(req)); + res.status(201).json(result); + } catch (error) { + res.status(error.code === 'LOCKED' ? 409 : 400).json({ error: error.message, code: error.code, lock: error.lock || null }); + } +}); + +app.post('/api/locks/:type/:id/renew', (req, res) => { + try { + const lock = renewResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token, ttlSeconds: req.body?.ttl_seconds || 120 }); + res.json({ lock }); + } catch (error) { res.status(409).json({ error: error.message, code: error.code, lock: error.lock || null }); } +}); + +app.delete('/api/locks/:type/:id', (req, res) => { + try { + const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token }); + if (changes) auditLog(req.user.id, req.user.email, 'lock.released', req.params.type, req.params.id, null, getClientIp(req)); + res.json({ ok: true, changes }); + } catch (error) { res.status(409).json({ error: error.message, code: error.code }); } +}); + +app.get('/api/security/locks', requirePermission('security.manage'), (_req, res) => res.json(listResourceLocks())); +app.delete('/api/security/locks/:type/:id', requirePermission('security.manage'), (req, res) => { + const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: '', force: true }); + auditLog(req.user.id, req.user.email, 'lock.force_released', req.params.type, req.params.id, null, getClientIp(req)); + res.json({ ok: true, changes }); +}); + +// --- Admin: User Management --- +app.get('/api/admin/users', requireRole('admin'), (_req, res) => { + res.json(getUsers()); +}); + +app.post('/api/admin/users/invite', requireRole('admin'), async (req, res) => { + const { email, name, role, method } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const userRole = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor'; + + const existing = getUserByEmail(email); + if (existing) return res.status(400).json({ error: 'User existiert bereits' }); + + const baseUrl = `${req.protocol}://${req.get('host')}`; + const ip = getClientIp(req); + + try { + if (method === 'auto_password') { + // Generate random password + const password = `Vd!${randomBytes(9).toString('base64url')}9a`; + const user = createUser(email, name || email.split('@')[0], userRole, req.user.id, password); + const mailResult = await sendCredentials(email, password, name || '', baseUrl); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}, Auto-Passwort`, ip); + res.json({ ok: true, emailSent: mailResult.sent, consoleFallback: mailResult.consoleFallback || false, password }); + } else { + // Magic link (existing behavior) + const { token } = createAuthToken(email, 'invite', userRole, req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}`, ip); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/admin/users/resend-invite', requireRole('admin'), async (req, res) => { + const { user_id } = req.body; + const user = getUser(user_id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + + const { token } = createAuthToken(user.email, 'invite', user.role, req.user.id); + const baseUrl = `${req.protocol}://${req.get('host')}`; + const result = await sendMagicLink(user.email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invite_resent', 'user', String(user.id), null, getClientIp(req)); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); +}); + +app.put('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + const existingUser = getUser(id); + if (!existingUser) return res.status(404).json({ error: 'User nicht gefunden' }); + if (req.user.id === id && req.body.role && req.body.role !== 'admin') { + return res.status(400).json({ error: 'Du kannst dir selbst nicht die Admin-Rolle entziehen' }); + } + const activeAdmins = getUsers().filter(user => user.role === 'admin' && user.active).length; + const removesLastAdmin = existingUser.role === 'admin' && existingUser.active && activeAdmins <= 1 && (req.body.role && req.body.role !== 'admin' || Number(req.body.active) === 0); + if (removesLastAdmin) return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht deaktiviert oder herabgestuft werden.' }); + const updated = updateUser(id, req.body); + if (!updated) return res.status(404).json({ error: 'User nicht gefunden' }); + auditLog(req.user.id, req.user.email, 'user.updated', 'user', String(id), JSON.stringify(req.body), getClientIp(req)); + res.json(updated); +}); + +app.delete('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + if (req.user.id === id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + if (user.role === 'admin' && user.active && getUsers().filter(entry => entry.role === 'admin' && entry.active).length <= 1) { + return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht gelöscht werden.' }); + } + deleteUser(id); + auditLog(req.user.id, req.user.email, 'user.deleted', 'user', String(id), user.email, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Sessions --- +app.get('/api/admin/sessions', requireRole('admin'), (_req, res) => { + res.json(getAllActiveSessions()); +}); + +app.delete('/api/admin/sessions/:id', requireRole('admin'), (req, res) => { + destroySessionById(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/admin/users/:id/sessions', requireRole('admin'), (req, res) => { + destroyUserSessions(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Audit Log --- +app.get('/api/admin/audit', requireRole('admin', 'manager'), (req, res) => { + const result = searchAuditLog({ + limit: req.query.limit, offset: req.query.offset, userId: req.query.user_id, + action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q, + }); + res.json(result); +}); + +app.get('/api/admin/audit/export.csv', requireRole('admin', 'manager'), (req, res) => { + const result = searchAuditLog({ limit: 1000, userId: req.query.user_id, action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q }); + const csvEscape = value => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = [['Zeit', 'Benutzer', 'Aktion', 'Zieltyp', 'Ziel-ID', 'Details', 'IP'].map(csvEscape).join(';')]; + for (const row of result.rows) lines.push([row.created_at, row.user_email, row.action, row.target_type, row.target_id, row.details, row.ip].map(csvEscape).join(';')); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="vendoo-audit-${new Date().toISOString().slice(0, 10)}.csv"`); + res.send('\uFEFF' + lines.join('\n')); +}); + +// --- Admin: Login History --- +app.get('/api/admin/login-history/:userId', requireRole('admin'), (req, res) => { + res.json(getLoginHistory(parseInt(req.params.userId))); +}); + +// --- Admin: SMTP Settings --- +app.get('/api/admin/smtp', requireRole('admin'), (_req, res) => { + const settings = getSettings(); + res.json({ + smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', + smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587', + smtp_user: settings.smtp_user || process.env.SMTP_USER || '', + smtp_pass: (settings.smtp_pass || process.env.SMTP_PASS) ? '••••••••' : '', + smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', + configured: isMailerConfigured(), + source: settings.smtp_host ? 'db' : (process.env.SMTP_HOST ? 'env' : 'none'), + }); +}); + +app.put('/api/admin/smtp', requireRole('admin'), (req, res) => { + const { smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from } = req.body; + const smtpSettings = {}; + if (smtp_host !== undefined) smtpSettings.smtp_host = smtp_host; + if (smtp_port !== undefined) smtpSettings.smtp_port = smtp_port; + if (smtp_user !== undefined) smtpSettings.smtp_user = smtp_user; + if (smtp_pass !== undefined && smtp_pass !== '••••••••') smtpSettings.smtp_pass = smtp_pass; + if (smtp_from !== undefined) smtpSettings.smtp_from = smtp_from; + updateSettings(smtpSettings); + + // Reinitialize mailer with new config + const allSettings = getSettings(); + if (allSettings.smtp_host && allSettings.smtp_user && allSettings.smtp_pass) { + const ok = reinitMailer({ + host: allSettings.smtp_host, + port: allSettings.smtp_port || '587', + user: allSettings.smtp_user, + pass: allSettings.smtp_pass, + }); + if (allSettings.smtp_from) process.env.SMTP_FROM = allSettings.smtp_from; + } + + if (req.user) auditLog(req.user.id, req.user.email, 'smtp.updated', null, null, null, getClientIp(req)); + res.json({ + smtp_host: allSettings.smtp_host || '', + smtp_port: allSettings.smtp_port || '587', + smtp_user: allSettings.smtp_user || '', + smtp_pass: allSettings.smtp_pass ? '••••••••' : '', + smtp_from: allSettings.smtp_from || '', + configured: isMailerConfigured(), + }); +}); + +app.post('/api/admin/smtp/test', requireRole('admin'), async (req, res) => { + try { + if (!isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' }); + const adminEmail = req.user?.email; + if (!adminEmail) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' }); + await testMailer(adminEmail); + auditLog(req.user.id, req.user.email, 'smtp.test_sent', null, null, `an ${adminEmail}`, getClientIp(req)); + res.json({ ok: true, message: `Testmail an ${adminEmail} gesendet` }); + } catch (err) { + res.status(500).json({ error: `SMTP-Fehler: ${err.message}` }); + } +}); + +// --- Admin: Password Reset --- +app.post('/api/admin/users/:id/reset-password', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + const { password } = req.body; + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + setPassword(id, password); + auditLog(req.user.id, req.user.email, 'user.password_reset', 'user', String(id), `für ${user.email}`, getClientIp(req)); + res.json({ ok: true, message: `Passwort für ${user.email} zurückgesetzt` }); +}); + +// --- Admin: User Login History --- +app.get('/api/admin/users/:id/login-history', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + res.json(getLoginHistory(id, 30)); +}); + +// --- Admin: System Diagnostics --- +function diagnosticItem(id, label, status, detail, meta = {}) { + return { id, label, status, detail, ...meta }; +} + +async function probeComfyUi(baseUrl) { + const normalized = String(baseUrl || 'http://127.0.0.1:8188').replace(/\/$/, ''); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1800); + try { + const response = await fetch(`${normalized}/system_stats`, { signal: controller.signal }); + if (!response.ok) return { ok: false, detail: `HTTP ${response.status}` }; + const payload = await response.json().catch(() => ({})); + const device = payload?.devices?.[0]; + const deviceName = device?.name || device?.type || 'ComfyUI'; + return { ok: true, detail: `Erreichbar · ${deviceName}` }; + } catch (error) { + return { ok: false, detail: error?.name === 'AbortError' ? 'Zeitüberschreitung' : (error?.message || 'Nicht erreichbar') }; + } finally { + clearTimeout(timer); + } +} + +app.get('/api/admin/diagnostics', requireRole('admin'), async (_req, res) => { + const startedAt = Date.now(); + const checks = []; + const root = __dirname; + const settings = getSettings(); + + try { + const result = db.pragma('quick_check', { simple: true }); + checks.push(diagnosticItem('database', 'Datenbankintegrität', result === 'ok' ? 'ok' : 'error', result === 'ok' ? 'SQLite quick_check: ok' : String(result))); + } catch (error) { + checks.push(diagnosticItem('database', 'Datenbankintegrität', 'error', error.message)); + } + + const writableTargets = [ + ['uploads', 'Upload-Verzeichnis', UPLOADS_DIR], + ['db-write', 'Datenbank-Verzeichnis', dirname(DB_PATH)], + ]; + for (const [id, label, target] of writableTargets) { + try { + mkdirSync(target, { recursive: true }); + const probe = join(target, `.vendoo-write-test-${process.pid}-${Date.now()}.tmp`); + writeFileSync(probe, 'ok'); + unlinkSync(probe); + checks.push(diagnosticItem(id, label, 'ok', 'Schreibzugriff vorhanden')); + } catch (error) { + checks.push(diagnosticItem(id, label, 'error', error.message)); + } + } + + const requiredAssets = [ + ['ui-html', 'UI-Dokument', join(root, 'public', 'index.html')], + ['ui-js', 'UI-Logik', join(root, 'public', 'app.js')], + ['ui-css', 'UI-Styles', join(root, 'public', 'style.css')], + ['flux-workflow', 'FLUX-Workflow', join(root, String(settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'))], + ]; + for (const [id, label, target] of requiredAssets) { + const present = existsSync(target); + checks.push(diagnosticItem(id, label, present ? 'ok' : 'error', present ? basename(target) : `Fehlt: ${target}`)); + } + + const extensionTargets = [ + ['Chrome', join(root, 'extensions', 'chrome', 'manifest.json')], + ['Edge', join(root, 'extensions', 'edge', 'manifest.json')], + ['Firefox', join(root, 'extensions', 'firefox', 'manifest.json')], + ['Safari', join(root, 'extensions', 'safari', 'web-extension', 'manifest.json')], + ]; + const extensionStates = extensionTargets.map(([name, target]) => ({ name, present: existsSync(target) })); + const extensionMissing = extensionStates.filter(item => !item.present).map(item => item.name); + checks.push(diagnosticItem('extensions', 'Browser-Extensions', extensionMissing.length ? 'warning' : 'ok', extensionMissing.length ? `Fehlend: ${extensionMissing.join(', ')}` : 'Chrome, Edge, Firefox und Safari vorhanden')); + + const configuredProviders = getProviders().map(provider => { + const configured = !provider.needsKey || Boolean(process.env[provider.needsKey]); + return { id: provider.id, configured }; + }); + const activeProvider = String(settings.ai_provider || settings.default_ai_provider || 'nicht festgelegt'); + checks.push(diagnosticItem('ai-provider', 'AI-Konfiguration', configuredProviders.some(item => item.configured) ? 'ok' : 'warning', `Aktiv: ${activeProvider} · verfügbar: ${configuredProviders.filter(item => item.configured).map(item => item.id).join(', ') || 'keiner'}`)); + + const comfyUrl = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + const comfy = await probeComfyUi(comfyUrl); + checks.push(diagnosticItem('comfyui', 'ComfyUI / FLUX', comfy.ok ? 'ok' : 'optional', `${comfy.detail} · ${comfyUrl}`)); + + const counts = checks.reduce((acc, check) => { acc[check.status] = (acc[check.status] || 0) + 1; return acc; }, {}); + const hardFailures = checks.filter(check => check.status === 'error').length; + res.setHeader('Cache-Control', 'no-store'); + res.json({ + ok: hardFailures === 0, + version: APP_VERSION, + generated_at: new Date().toISOString(), + duration_ms: Date.now() - startedAt, + summary: counts, + checks, + runtime: { node: process.version, platform: process.platform, arch: process.arch, uptime_seconds: Math.floor(process.uptime()), memory_mb: Math.round(process.memoryUsage().rss / 1048576) }, + }); +}); + +// --- Admin: System Info --- +app.get('/api/admin/system', requireRole('admin'), (_req, res) => { + const users = getUsers(); + const listings = getListings(); + const sessions = getAllActiveSessions(); + + let dbSize = 'unbekannt'; + try { + const dbPath = DB_PATH; + if (existsSync(dbPath)) { + const bytes = statSync(dbPath).size; + if (bytes < 1024) dbSize = bytes + ' B'; + else if (bytes < 1024 * 1024) dbSize = (bytes / 1024).toFixed(1) + ' KB'; + else dbSize = (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + } catch {} + + const uptimeSeconds = process.uptime(); + const hours = Math.floor(uptimeSeconds / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + + res.json({ + node_version: process.version, + uptime, + uptime_seconds: Math.floor(uptimeSeconds), + total_users: users.length, + total_listings: listings.length, + active_sessions: sessions.length, + db_size: dbSize, + platform: process.platform, + memory_mb: Math.round(process.memoryUsage().rss / (1024 * 1024)), + }); +}); + +// --- Admin: Session Cleanup --- +app.post('/api/admin/sessions/cleanup', requireRole('admin'), (req, res) => { + try { + cleanExpiredSessions(); + auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, getClientIp(req)); + res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' }); + } catch (err) { + res.json({ ok: false, error: err.message }); + } +}); + +// --- My Sessions --- +app.get('/api/my/sessions', (req, res) => { + if (isSetupMode()) return res.json([]); + res.json(getUserSessions(req.user.id)); +}); + +app.delete('/api/my/sessions/:id', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + destroySessionById(parseInt(req.params.id)); + res.json({ ok: true }); +}); + +// Static files served for everyone — auth check happens via /api/me in frontend +app.use((req, res, next) => { + const path = String(req.path || '').toLowerCase(); + if (path === '/' || path.endsWith('.html') || path.endsWith('.js') || path.endsWith('.css')) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); + } + next(); +}); +app.use(express.static(join(__dirname, 'public'), { etag: true, maxAge: 0 })); +app.use('/uploads', requireAuth, express.static(UPLOADS_DIR, { etag: true, maxAge: '1h', fallthrough: false })); + +function sanitizeUploadPlatform(value) { + const clean = String(value || 'allgemein').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + return clean.slice(0, 48) || 'allgemein'; +} + +const storage = multer.diskStorage({ + destination: (req, _file, cb) => { + const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); + const dir = join(UPLOADS_DIR, platform); + mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + cb(null, `${randomUUID()}${ext}`); + }, +}); +const upload = multer({ + storage, + fileFilter: (_req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']; + const ext = extname(file.originalname).toLowerCase(); + const isImageMime = String(file.mimetype || '').startsWith('image/'); + cb(null, allowed.includes(ext) && isImageMime); + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}); + + +const operationsUpload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => { const dir = join(OPERATIONS_DIR, 'staging'); mkdirSync(dir, { recursive: true }); cb(null, dir); }, + filename: (_req, file, cb) => cb(null, `${Date.now()}-${randomUUID()}${extname(file.originalname || '').toLowerCase() || '.bin'}`), + }), + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname || '').toLowerCase(); + cb(null, ['.zip', '.db', '.sqlite', '.sqlite3'].includes(ext)); + }, + limits: { fileSize: 2 * 1024 * 1024 * 1024 }, +}); + + + +async function normalizeMobileUploadFile(file) { + const ext = extname(file.originalname || file.filename || '').toLowerCase(); + if (!['.heic', '.heif'].includes(ext) && !/hei[cf]/i.test(file.mimetype || '')) { + return file; + } + + const outputName = `${randomUUID()}.jpg`; + const outputPath = join(dirname(file.path), outputName); + try { + await sharp(file.path).rotate().jpeg({ quality: 90 }).toFile(outputPath); + try { unlinkSync(file.path); } catch {} + return { + ...file, + filename: outputName, + path: outputPath, + mimetype: 'image/jpeg', + originalname: String(file.originalname || 'foto.heic').replace(/\.(heic|heif)$/i, '.jpg'), + }; + } catch (error) { + try { unlinkSync(file.path); } catch {} + throw new Error('HEIC/HEIF konnte auf diesem System nicht in JPG umgewandelt werden. Bitte am Smartphone „Am kompatibelsten“ wählen oder als JPG/PNG hochladen.'); + } +} + + +function validateMobileUploadToken(req, res, next) { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + if ((session.files || []).length >= 10) return res.status(400).json({ error: 'Maximal 10 Bilder pro Upload-Sitzung.' }); + req.mobileUploadSession = session; + next(); +} + +app.post('/mobile-upload-api/:token/photos', validateMobileUploadToken, upload.array('photos', 10), async (req, res) => { + const session = req.mobileUploadSession; + const remaining = Math.max(0, 10 - (session.files || []).length); + const uploadedFiles = req.files || []; + const acceptedFiles = uploadedFiles.slice(0, remaining); + for (const extraFile of uploadedFiles.slice(remaining)) { + try { unlinkSync(extraFile.path); } catch {} + } + + try { + const normalizedFiles = []; + for (const file of acceptedFiles) normalizedFiles.push(await normalizeMobileUploadFile(file)); + const platform = req.body?.platform || req.query?.platform || 'allgemein'; + const filenames = normalizedFiles.map(file => `${platform}/${file.filename}`); + const updated = appendMobileUploadFiles(session.id, filenames); + res.json({ + ok: true, + files: updated?.files || [], + added: filenames.length, + remaining: Math.max(0, 10 - (updated?.files || []).length), + }); + } catch (error) { + for (const file of acceptedFiles) { + try { if (existsSync(file.path)) unlinkSync(file.path); } catch {} + } + res.status(400).json({ error: error.message || 'Bilder konnten nicht verarbeitet werden.' }); + } +}); + +// --- Platforms --- +const platforms = {}; +async function loadPlatforms() { + for (const f of ['vinted', 'ebay-ka', 'ebay-de', 'etsy']) { + const mod = await import(`./platforms/${f}.mjs`); + platforms[mod.default.id] = mod.default; + } +} + +// --- Upload --- +app.post('/api/upload', upload.array('photos', 10), (req, res) => { + const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); + res.json({ filenames: req.files.map(f => `${platform}/${f.filename}`) }); +}); + +app.get('/api/image-editor/versions', async (req, res) => { + try { + const path = String(req.query?.path || '').trim(); + const resolved = resolveEditableImage(UPLOADS_DIR, path); + const history = getImageEditVersions(resolved.normalized, req.query?.limit || 50); + const original = resolveEditableImage(UPLOADS_DIR, history.root_path || resolved.normalized); + const stats = statSync(original.absolute); + const metadata = await sharp(original.absolute, { failOn: 'none' }).metadata(); + res.json({ + root_path: history.root_path || resolved.normalized, + current_path: resolved.normalized, + original: { + id: 'original', + output_path: history.root_path || resolved.normalized, + root_path: history.root_path || resolved.normalized, + source_path: null, + context: 'original', + operations: {}, + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || null, + file_size: stats.size, + created_at: stats.birthtime?.toISOString?.() || stats.mtime?.toISOString?.() || null, + }, + versions: history.versions, + }); + } catch (error) { + res.status(400).json({ error: error.message || 'Bildversionen konnten nicht geladen werden.' }); + } +}); + +app.post('/api/image-editor/render', async (req, res) => { + try { + const result = await renderImageVersion({ + uploadRoot: UPLOADS_DIR, + sourcePath: req.body?.source_path, + operations: req.body?.operations, + context: req.body?.context || 'generator', + userId: req.user?.id || null, + rootPath: getImageEditRoot(String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()) || String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(), + recordVersion: createImageEditVersion, + }); + const history = getImageEditVersions(result.filename, 50); + res.status(201).json({ ...result, versions: history.versions }); + } catch (error) { + res.status(400).json({ error: error.message || 'Bildversion konnte nicht erstellt werden.' }); + } +}); + + +app.get('/api/network-info', (_req, res) => { + const addresses = getLanIpv4Addresses(); + res.json({ + port: Number(PORT), + addresses, + preferred_url: addresses[0]?.address ? `http://${addresses[0].address}:${PORT}` : `http://localhost:${PORT}`, + public_base_url: String(process.env.PUBLIC_BASE_URL || '').trim() || null, + runtime_config_path: CONFIG_PATH, + }); +}); + +app.post('/api/mobile-upload-sessions', (req, res) => { + cleanupMobileUploadSessions(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashMobileUploadToken(token); + const context = ['generator', 'listing'].includes(req.body?.context) ? req.body.context : 'generator'; + const listingId = req.body?.listing_id ? Number(req.body.listing_id) : null; + if (context === 'listing' && listingId && !getListing(listingId)) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + const expires = new Date(Date.now() + 20 * 60 * 1000); + const expiresSql = expires.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + const session = createMobileUploadSession({ + token_hash: tokenHash, + user_id: req.user?.id || null, + listing_id: listingId, + context, + expires_at: expiresSql, + }); + const baseUrl = resolvePublicBaseUrl(req); + const url = `${baseUrl}/mobile-upload/${encodeURIComponent(token)}`; + res.json({ + id: session.id, + url, + base_url: baseUrl, + detected_lan_ip: getLanIpv4Addresses()[0]?.address || null, + token, + context, + listing_id: listingId, + files: [], + expires_at: expires.toISOString(), + }); +}); + +app.get('/api/mobile-upload-sessions/:id', (req, res) => { + cleanupMobileUploadSessions(); + const session = getMobileUploadSessionById(Number(req.params.id), req.user?.id || null); + if (!session || !isMobileUploadSessionUsable(session)) return res.status(404).json({ error: 'Upload-Sitzung nicht gefunden oder abgelaufen' }); + res.json({ id: session.id, context: session.context, listing_id: session.listing_id, files: session.files || [], expires_at: session.expires_at, status: session.status }); +}); + +app.delete('/api/mobile-upload-sessions/:id', (req, res) => { + deleteMobileUploadSession(Number(req.params.id), req.user?.id || null); + res.json({ ok: true }); +}); + +// --- Image tools --- +app.post('/api/images/remove-bg', async (req, res) => { + try { + const { filename } = req.body; + const newName = await removeBackground(filename); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/images/watermark', async (req, res) => { + try { + const { filename, text } = req.body; + const newName = await addWatermark(filename, text); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- AI Model Photos --- +app.get('/api/listings/:id/ai-model-generations', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const generations = getAiModelGenerationsForListing(req.params.id).map(item => ({ + ...item, + photos: getAiModelPhotosForGeneration(item.id), + })); + res.json(generations); +}); + +app.get('/api/ai-model-photos/jobs/:id', (req, res) => { + const job = getAiModelJobWithRelations(req.params.id); + if (!job) return res.status(404).json({ error: 'Nicht gefunden' }); + const response = { ...job }; + if (job.generation_id) { + response.photos = getAiModelPhotosForGeneration(job.generation_id); + } + res.json(response); +}); + +app.post('/api/ai-model-photos/jobs', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const precheck = await precheckAiModelPhotos(payload, settings); + if (!precheck.allowed) return res.status(400).json(precheck); + const job = enqueueAiModelJob({ ...payload, settings, preset: precheck.preset, variants: precheck.variants, mode: precheck.mode }); + res.json({ queued: true, job_id: job.id, job, precheck }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/diagnostics', async (_req, res) => { + try { + const diagnostics = await getOpenRouterImageDiagnostics(); + res.json({ + ...diagnostics, + has_api_key: !!process.env.OPENROUTER_API_KEY, + free_only_enabled: String(getSettings().ai_model_photo_openrouter_free_only || '1') !== '0', + }); + } catch (err) { + res.status(500).json({ error: err.message, has_api_key: !!process.env.OPENROUTER_API_KEY }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/models', async (req, res) => { + try { + const settings = getSettings(); + const freeOnly = req.query.free_only !== undefined + ? String(req.query.free_only) !== '0' + : String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const models = await fetchOpenRouterImageModels({ freeOnly }); + res.json({ provider: 'openrouter', free_only: freeOnly, models }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +function getLocalFluxFileInfo(pathname) { + try { + if (!existsSync(pathname)) return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; + const stats = statSync(pathname); + return { + exists: true, + size_bytes: stats.size, + size_mb: Math.round((stats.size / 1024 / 1024) * 10) / 10, + size_gb: Math.round((stats.size / 1024 / 1024 / 1024) * 100) / 100, + modified_at: stats.mtime?.toISOString?.() || null, + }; + } catch { + return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; + } +} + +function getLocalFluxDirectoryMetrics(rootPath) { + const result = { exists: false, file_count: 0, size_bytes: 0, size_mb: 0 }; + if (!existsSync(rootPath)) return result; + result.exists = true; + const stack = [rootPath]; + while (stack.length) { + const current = stack.pop(); + let entries = []; + try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile()) { + result.file_count += 1; + try { result.size_bytes += statSync(full).size; } catch {} + } + } + } + result.size_mb = Math.round((result.size_bytes / 1024 / 1024) * 10) / 10; + return result; +} + +function stripAnsiSequences(value = '') { + return String(value || '').replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function readLocalFluxLogTail(logPath, maxLines = 40) { + try { + if (!existsSync(logPath)) return []; + return readFileSync(logPath, 'utf-8') + .split(/\r?\n/) + .map(stripAnsiSequences) + .filter(Boolean) + .slice(-maxLines); + } catch { + return []; + } +} + +function readLocalFluxRuntimeState(includeLogs = false) { + const logDir = LOG_DIR; + const statePath = join(logDir, 'local-flux-runtime-state.json'); + const stdoutPath = join(logDir, 'local-flux-runtime.log'); + const stderrPath = join(logDir, 'local-flux-runtime-error.log'); + let state = { + status: 'stopped', + message: 'FLUX / ComfyUI ist nicht gestartet.', + pid: null, + elapsed_seconds: 0, + updated_at: null, + error: '', + }; + if (existsSync(statePath)) { + try { + state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; + } catch { + state = { ...state, status: 'unknown', message: 'FLUX-Runtime-Status konnte nicht gelesen werden.' }; + } + } + const updatedAt = Date.parse(state.updated_at || '') || 0; + const ageSeconds = updatedAt ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : null; + const stale = state.status === 'starting' && ageSeconds !== null && ageSeconds > 240; + if (stale) { + state = { + ...state, + status: 'failed', + message: `FLUX-Startstatus ist seit ${ageSeconds} Sekunden veraltet.`, + error: state.error || 'Der Startprozess liefert keine Statusaktualisierung mehr.', + }; + } + return { + ...state, + age_seconds: ageSeconds, + stale, + state_path: statePath, + stdout_log: stdoutPath, + stderr_log: stderrPath, + ...(includeLogs ? { + stdout_tail: readLocalFluxLogTail(stdoutPath, 30), + stderr_tail: readLocalFluxLogTail(stderrPath, 30), + } : {}), + }; +} + +function findLatestLocalFluxArchive(downloadDir) { + try { + const items = readdirSync(downloadDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && /\.7z$/i.test(entry.name)) + .map(entry => ({ name: entry.name, path: join(downloadDir, entry.name), info: getLocalFluxFileInfo(join(downloadDir, entry.name)) })) + .sort((a, b) => String(b.info.modified_at || '').localeCompare(String(a.info.modified_at || ''))); + return items[0] || null; + } catch { + return null; + } +} + +function readLocalFluxInstallState(includeLog = false) { + const localRoot = join(__dirname, 'local-ai'); + const statePath = join(localRoot, 'install-state.json'); + const logPath = join(LOG_DIR, 'local-flux-install.log'); + const portableRoot = join(localRoot, 'comfyui', 'ComfyUI_windows_portable'); + const comfyMain = join(portableRoot, 'ComfyUI', 'main.py'); + const embeddedPython = join(portableRoot, 'python_embeded', 'python.exe'); + const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'); + const workflowPath = join(localRoot, 'workflows', 'vendoo_flux_schnell_api.json'); + const startScript = join(localRoot, 'start-local-flux.ps1'); + const stopScript = join(localRoot, 'stop-local-flux.ps1'); + const downloadDir = join(localRoot, 'downloads'); + const extractTemp = join(localRoot, 'extract-temp'); + const sevenZipOutputPath = join(LOG_DIR, 'local-flux-7zip-output.log'); + const sevenZipErrorPath = join(LOG_DIR, 'local-flux-7zip-error.log'); + const latestArchive = findLatestLocalFluxArchive(downloadDir); + const partialFiles = (() => { + try { + return readdirSync(downloadDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && /\.part$/i.test(entry.name)) + .map(entry => ({ name: entry.name, ...getLocalFluxFileInfo(join(downloadDir, entry.name)) })); + } catch { return []; } + })(); + + let state = { + status: 'not-started', + phase: 'idle', + progress: 0, + message: 'Lokale FLUX-Installation wurde noch nicht gestartet.', + }; + if (existsSync(statePath)) { + try { + state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; + } catch { + state = { ...state, status: 'unknown', phase: 'unknown', message: 'Installationsstatus konnte nicht gelesen werden.' }; + } + } + + const known7ZipPaths = [ + process.env.ProgramFiles ? join(process.env.ProgramFiles, '7-Zip', '7z.exe') : null, + process.env['ProgramFiles(x86)'] ? join(process.env['ProgramFiles(x86)'], '7-Zip', '7z.exe') : null, + process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', '7-Zip', '7z.exe') : null, + ].filter(Boolean); + const sevenZipPath = known7ZipPaths.find(pathname => existsSync(pathname)) || null; + + const checks = { + seven_zip: { ok: !!sevenZipPath, path: sevenZipPath }, + archive: { ok: !!latestArchive?.info?.exists, name: latestArchive?.name || null, ...(latestArchive?.info || {}) }, + embedded_python: { ok: existsSync(embeddedPython), path: embeddedPython }, + comfyui: { ok: existsSync(comfyMain), path: comfyMain }, + model: { ok: existsSync(modelPath) && getLocalFluxFileInfo(modelPath).size_bytes >= 1024 * 1024 * 1024, path: modelPath, ...getLocalFluxFileInfo(modelPath) }, + workflow: { ok: existsSync(workflowPath), path: workflowPath }, + start_script: { ok: existsSync(startScript), path: startScript }, + stop_script: { ok: existsSync(stopScript), path: stopScript }, + }; + + const completedChecks = Object.values(checks).filter(check => check.ok).length; + const totalChecks = Object.keys(checks).length; + const readiness = Math.round((completedChecks / totalChecks) * 100); + const phaseLabels = { + idle: 'Noch nicht gestartet', + preflight: 'Vorprüfung', + 'download-comfyui': 'ComfyUI wird heruntergeladen', + 'dependency-7zip': '7-Zip wird geprüft', + 'install-7zip': '7-Zip wird installiert', + 'extract-comfyui': 'ComfyUI wird entpackt', + 'install-comfyui': 'ComfyUI-Struktur wird installiert', + configure: 'Lokale Konfiguration wird erstellt', + 'download-model': 'FLUX-Modell wird heruntergeladen', + autostart: 'Autostart wird eingerichtet', + start: 'Lokaler Bildserver wird gestartet', + update: 'ComfyUI wird aktualisiert', + done: 'Installation abgeschlossen', + error: 'Installation fehlgeschlagen', + uninstalled: 'Nicht installiert', + }; + + const extractMetrics = getLocalFluxDirectoryMetrics(extractTemp); + const now = Date.now(); + const heartbeatAt = Date.parse(state.heartbeat_at || state.updated_at || '') || 0; + const heartbeatAgeSeconds = heartbeatAt ? Math.max(0, Math.round((now - heartbeatAt) / 1000)) : null; + const staleThresholdSeconds = state.phase === 'extract-comfyui' ? 15 : 120; + const stalled = state.status === 'running' && heartbeatAgeSeconds !== null && heartbeatAgeSeconds > staleThresholdSeconds; + const installationComplete = checks.embedded_python.ok && checks.comfyui.ok && checks.model.ok && checks.workflow.ok && checks.start_script.ok && checks.stop_script.ok; + let effectiveStatus = stalled ? 'stalled' : state.status; + let effectivePhase = state.phase; + let effectiveMessage = stalled + ? `Keine Statusaktualisierung seit ${heartbeatAgeSeconds} Sekunden. Der Installer oder 7-Zip könnte hängen.` + : state.message; + + // Der reale Dateistand hat Vorrang vor einer alten, beschädigten oder fehlenden Statusdatei. + if (installationComplete && effectiveStatus !== 'running') { + effectiveStatus = 'completed'; + effectivePhase = 'done'; + effectiveMessage = 'Lokales FLUX ist vollständig installiert.'; + } else if (!checks.comfyui.ok && ['completed', 'unknown'].includes(effectiveStatus)) { + effectiveStatus = 'not-started'; + effectivePhase = 'idle'; + effectiveMessage = 'Lokales FLUX ist noch nicht vollständig installiert.'; + } + + const result = { + ...state, + status: effectiveStatus, + original_status: state.status, + phase: effectivePhase, + message: effectiveMessage, + phase_label: stalled ? 'Keine Statusaktualisierung' : (phaseLabels[effectivePhase] || effectivePhase || 'Unbekannt'), + checks, + readiness, + heartbeat_age_seconds: heartbeatAgeSeconds, + stale_threshold_seconds: staleThresholdSeconds, + stalled, + extract_metrics: { + file_count: Number(state.extract_files ?? extractMetrics.file_count ?? 0), + size_mb: Number(state.extract_mb ?? extractMetrics.size_mb ?? 0), + elapsed_seconds: Number(state.extract_elapsed_seconds ?? 0), + process_id: state.extract_process_id || null, + root: extractTemp, + }, + installer_process_id: state.process_id || null, + completed_checks: completedChecks, + total_checks: totalChecks, + installation_complete: installationComplete, + can_resume: ['failed', 'stalled'].includes(effectiveStatus) || ((checks.archive.ok || checks.comfyui.ok || checks.embedded_python.ok) && (!checks.comfyui.ok || !checks.model.ok)), + partial_downloads: partialFiles, + paths: { + state_file: statePath, + log_file: logPath, + portable_root: portableRoot, + model_path: modelPath, + workflow_path: workflowPath, + extract_root: extractTemp, + seven_zip_output: sevenZipOutputPath, + seven_zip_error: sevenZipErrorPath, + }, + }; + if (includeLog) { + result.log_tail = readLocalFluxLogTail(logPath, 80); + result.seven_zip_output_tail = readLocalFluxLogTail(sevenZipOutputPath, 30); + result.seven_zip_error_tail = readLocalFluxLogTail(sevenZipErrorPath, 30); + } + return result; +} + +function spawnLocalFluxPowerShell(scriptName, args = []) { + if (process.platform !== 'win32') { + throw new Error('Die automatische lokale FLUX-Installation ist aktuell für Windows vorgesehen.'); + } + const scriptPath = join(__dirname, 'scripts', scriptName); + if (!existsSync(scriptPath)) throw new Error(`Skript fehlt: ${scriptName}`); + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args], { + cwd: __dirname, + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + return child.pid; +} + +app.get('/api/local-image/status', async (_req, res) => { + try { + const settings = getSettings(); + const runtime = await getLocalImageStatus(settings); + const install = readLocalFluxInstallState(); + const runtimeState = readLocalFluxRuntimeState(true); + const engineState = !runtime.enabled + ? 'disabled' + : runtime.reachable + ? 'running' + : runtimeState.status === 'starting' + ? 'starting' + : runtimeState.status === 'failed' + ? 'failed' + : install.installation_complete + ? 'stopped' + : 'not-installed'; + res.json({ + ...runtime, + install, + runtime_state: runtimeState, + engine_state: engineState, + engine_label: engineState === 'running' + ? 'Aktiv und bereit' + : engineState === 'starting' + ? 'Wird gestartet' + : engineState === 'failed' + ? 'Start fehlgeschlagen' + : engineState === 'stopped' + ? 'Installiert, aber gestoppt' + : engineState === 'disabled' + ? 'Deaktiviert' + : 'Nicht installiert', + }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler Bildstatus konnte nicht geprüft werden.' }); + } +}); + +app.get('/api/local-image/install-status', requireRole('admin'), (_req, res) => { + res.json(readLocalFluxInstallState(true)); +}); + +app.post('/api/local-image/install', requireRole('admin'), (req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Eine lokale FLUX-Installation läuft bereits.', install: current }); + } + const args = ['-NonInteractive']; + if (req.body?.install_model !== false) args.push('-InstallModel'); + if (req.body?.enable_autostart !== false) args.push('-EnableAutostart'); + if (req.body?.start_now !== false) args.push('-StartNow'); + if (req.body?.force === true) args.push('-Force'); + const gpu = String(req.body?.gpu || 'auto').toLowerCase(); + if (['auto','nvidia','amd','intel','cpu'].includes(gpu)) args.push('-Gpu', gpu); + const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', args); + res.json({ ok: true, pid, message: 'Lokale FLUX-Installation wurde gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokale FLUX-Installation konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/update', requireRole('admin'), (_req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Eine lokale FLUX-Aktion läuft bereits.', install: current }); + } + const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', ['-NonInteractive', '-UpdateOnly', '-StartNow']); + res.json({ ok: true, pid, message: 'Lokales FLUX-Update wurde gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokales FLUX-Update konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/start', requireRole('admin'), (req, res) => { + try { + const settings = getSettings(); + const requested = String(req.body?.profile || settings.flux_runtime_profile || 'auto').toLowerCase(); + const profile = ['auto','fast','balanced','lowvram'].includes(requested) ? requested : 'auto'; + const pid = spawnLocalFluxPowerShell('../local-ai/start-local-flux.ps1', ['-Profile', profile]); + res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/stop', requireRole('admin'), (_req, res) => { + try { + const pid = spawnLocalFluxPowerShell('../local-ai/stop-local-flux.ps1', []); + res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird beendet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht beendet werden.' }); + } +}); + + +app.post('/api/local-image/uninstall', requireRole('admin'), (req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Während einer laufenden Installation kann FLUX nicht deinstalliert werden.', install: current }); + } + const args = ['-NonInteractive']; + if (req.body?.remove_downloads === true) args.push('-RemoveDownloads'); + const pid = spawnLocalFluxPowerShell('uninstall-local-flux.ps1', args); + + updateSettings({ ai_model_photo_local_enabled: '0' }); + process.env.LOCAL_IMAGE_ENABLED = 'false'; + updateRuntimeConfig({ LOCAL_IMAGE_ENABLED: 'false' }); + + res.json({ + ok: true, + pid, + message: req.body?.remove_downloads === true + ? 'Lokales FLUX wird vollständig einschließlich Downloads deinstalliert.' + : 'Lokales FLUX wird deinstalliert. Installationsarchive bleiben für eine schnelle Neuinstallation erhalten.', + }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokales FLUX konnte nicht deinstalliert werden.' }); + } +}); + +app.post('/api/local-image/regenerate-token', requireRole('admin'), (req, res) => { + try { + const token = generateLocalImageToken(); + updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token }); + res.json({ ok: true, token }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler Token konnte nicht erneuert werden.' }); + } +}); + + + +app.get('/api/media-library', async (req, res) => { + try { + const allItems = listMediaLibraryItems({ + source: req.query?.source || 'all', + search: req.query?.q || '', + favoritesOnly: String(req.query?.favorites || '0') === '1', + sort: req.query?.sort || 'newest', + }); + const pageSize = Math.max(12, Math.min(96, Number(req.query?.page_size) || 24)); + const total = allItems.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min(Math.max(1, Number(req.query?.page) || 1), totalPages); + const uploadRoot = resolve(UPLOADS_DIR); + const pageItems = allItems.slice((page - 1) * pageSize, page * pageSize); + const items = await Promise.all(pageItems.map(async item => { + const absolute = resolve(uploadRoot, item.image_path); + let fileSize = null; + let width = Number(item.width) || null; + let height = Number(item.height) || null; + let format = item.format || extname(item.image_path).replace('.', '').toLowerCase() || null; + if (absolute !== uploadRoot && absolute.startsWith(uploadRoot + sep) && existsSync(absolute)) { + try { fileSize = statSync(absolute).size; } catch {} + if (!width || !height) { + try { + const metadata = await sharp(absolute, { failOn: 'none' }).metadata(); + width = metadata.width || width; + height = metadata.height || height; + format = metadata.format || format; + } catch {} + } + } + return { ...item, public_url: `/uploads/${item.image_path}`, file_size: fileSize, width, height, format }; + })); + const summaryItems = listMediaLibraryItems({ + source: 'all', + search: req.query?.q || '', + favoritesOnly: String(req.query?.favorites || '0') === '1', + sort: req.query?.sort || 'newest', + }); + const sourceCounts = summaryItems.reduce((counts, item) => { + for (const itemSource of item.sources || []) counts[itemSource] = (counts[itemSource] || 0) + 1; + return counts; + }, {}); + res.json({ items, pagination: { page, page_size: pageSize, total, total_pages: totalPages }, summary: { total: summaryItems.length, sources: sourceCounts } }); + } catch (error) { res.status(500).json({ error: error.message || 'Medienbibliothek konnte nicht geladen werden.' }); } +}); + +app.patch('/api/media-library/favorite', (req, res) => { + const result = setMediaLibraryFavorite(req.body?.image_path, req.body?.favorite !== false); + if (!result.ok) return res.status(400).json({ error: 'Nur FLUX-Bilder können als Favorit markiert werden.' }); + res.json(result); +}); + +app.delete('/api/media-library/items', (req, res) => { + const requested = [...new Set((Array.isArray(req.body?.image_paths) ? req.body.image_paths : []).map(value => String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()).filter(Boolean))].slice(0, 500); + const referenced = getListingReferencedMediaPaths(); + const protectedPaths = requested.filter(imagePath => referenced.has(imagePath)); + const deletable = requested.filter(imagePath => !referenced.has(imagePath)); + const uploadRoot = resolve(UPLOADS_DIR); + const deletedFiles = []; + for (const imagePath of deletable) { + const absolute = resolve(uploadRoot, imagePath); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep)) continue; + try { if (existsSync(absolute) && statSync(absolute).isFile()) { unlinkSync(absolute); deletedFiles.push(imagePath); } } catch {} + } + removeMediaLibraryRecords(deletable); + res.json({ ok: true, deleted: deletable.length, deleted_files: deletedFiles.length, protected: protectedPaths }); +}); + +app.get('/api/image-batch/profiles', (_req, res) => { + res.json({ items: listImageBatchProfiles() }); +}); + +app.post('/api/image-batch/profiles', (req, res) => { + try { res.status(201).json(createImageBatchProfile(req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht erstellt werden.' }); } +}); + +app.patch('/api/image-batch/profiles/:id', (req, res) => { + try { res.json(updateImageBatchProfile(req.params.id, req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gespeichert werden.' }); } +}); + +app.delete('/api/image-batch/profiles/:id', (req, res) => { + try { res.json(deleteImageBatchProfile(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/image-batch/jobs', (req, res) => { + try { res.status(202).json(createImageBatchJob(req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht erstellt werden.' }); } +}); + +app.get('/api/image-batch/jobs', (req, res) => { + try { + res.json(paginateImageBatchJobs({ page: req.query?.page || 1, pageSize: req.query?.page_size || 10, status: req.query?.status || '' })); + } catch (error) { res.status(500).json({ error: error.message || 'Bildaufträge konnten nicht geladen werden.' }); } +}); + +app.get('/api/image-batch/jobs/:id', (req, res) => { + const job = getImageBatchJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/image-batch/jobs/:id/pause', (req, res) => { + try { res.json(pauseImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht pausiert werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/resume', (req, res) => { + try { res.json(resumeImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht fortgesetzt werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/cancel', (req, res) => { + try { res.json(cancelImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht abgebrochen werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/retry', (req, res) => { + try { res.json(retryImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht wiederholt werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/items/:itemId/retry', (req, res) => { + try { res.json(retryImageBatchItem(req.params.id, req.params.itemId)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildposition konnte nicht wiederholt werden.' }); } +}); + +app.delete('/api/image-batch/jobs/:id', (req, res) => { + try { res.json(deleteImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht gelöscht werden.' }); } +}); + +app.get('/api/image-batch/jobs/:id/archive', (req, res) => { + try { + const job = getImageBatchJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); + const outputs = getImageBatchOutputPaths(req.params.id); + if (!outputs.length) return res.status(404).json({ error: 'Dieser Auftrag enthält noch keine fertigen Bilder.' }); + const uploadRoot = resolve(UPLOADS_DIR); + const files = []; + for (const [index, relative] of outputs.entries()) { + const absolute = resolve(uploadRoot, relative); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) continue; + files.push({ name: `${String(index + 1).padStart(2, '0')}-${basename(relative)}`, data: readFileSync(absolute) }); + } + if (!files.length) return res.status(404).json({ error: 'Die fertigen Bilddateien wurden nicht gefunden.' }); + const archive = createZip(files); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-bildproduktion-${String(job.id).slice(0, 8)}.zip`); + res.setHeader('Content-Length', archive.length); + res.send(archive); + } catch (error) { res.status(500).json({ error: error.message || 'ZIP-Export konnte nicht erstellt werden.' }); } +}); + +app.post('/api/flux-prompt/jobs', (req, res) => { + try { + const settings = getSettings(); + const job = enqueueFluxPromptJob({ + prompt: req.body?.prompt, + width: req.body?.width, + height: req.body?.height, + seed: req.body?.seed, + steps: req.body?.steps, + style: req.body?.style, + profile: req.body?.profile || settings.flux_default_profile || 'balanced', + promptMode: req.body?.prompt_mode || 'precise', + assistant: req.body?.assistant !== false && String(settings.flux_prompt_assistant || '1') !== '0', + variants: req.body?.variants || settings.flux_default_variants || 1, + seedLock: req.body?.seed_lock === true || String(req.body?.seed_lock || settings.flux_seed_lock || '0') === '1', + }); + res.status(202).json(job); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht erstellt werden.' }); } +}); + +app.get('/api/flux-prompt/jobs', (req, res) => { + const pageData = paginateFluxPromptJobs({ + page: req.query?.page || 1, + pageSize: req.query?.page_size || req.query?.limit || 10, + status: req.query?.status || '', + archived: String(req.query?.archived || '0') === '1', + }); + res.json({ ...pageData, summary: getFluxPromptJobSummary() }); +}); + +app.post('/api/flux-prompt/jobs/archive', (req, res) => { + res.json(archiveFluxPromptJobs(req.body?.ids || [])); +}); + +app.post('/api/flux-prompt/jobs/restore', (req, res) => { + res.json(restoreFluxPromptJobs(req.body?.ids || [])); +}); + +app.delete('/api/flux-prompt/jobs/archive', (req, res) => { + res.json(deleteArchivedFluxPromptJobs(req.body?.ids || [], { all: String(req.query?.all || '0') === '1' })); +}); + +app.get('/api/flux-prompt/jobs/:id', (req, res) => { + const job = getFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/flux-prompt/jobs/:id/cancel', async (req, res) => { + const job = await cancelFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/flux-prompt/jobs/:id/retry', (req, res) => { + try { + const job = retryFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.status(202).json(job); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht wiederholt werden.' }); } +}); + +app.delete('/api/flux-prompt/jobs/queue', (_req, res) => { + res.json(clearFluxPromptQueue()); +}); + +app.get('/api/flux-prompt/runtime', async (_req, res) => { + try { + const settings = getSettings(); + const runtime = await getLocalFluxRuntimeInfo(settings, { historyLimit: 20 }); + res.json({ ...runtime, vendoo_jobs: getFluxPromptJobSummary() }); + } catch (error) { + res.status(503).json({ error: error.message || 'ComfyUI-Systemdaten konnten nicht geladen werden.', vendoo_jobs: getFluxPromptJobSummary() }); + } +}); + +app.get('/api/flux-studio/images', (req, res) => { + const favoritesOnly = String(req.query?.favorites || '0') === '1'; + if (req.query?.page !== undefined || req.query?.page_size !== undefined) { + const result = paginateFluxImages({ page: req.query.page, pageSize: req.query.page_size, favoritesOnly }); + return res.json({ + items: result.items.map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })), + pagination: result.pagination, + }); + } + const items = getFluxImages(req.query?.limit || 40, { favoritesOnly }).map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })); + res.json(items); +}); + +app.post('/api/flux-prompt/improve', async (req, res) => { + try { + const prompt = String(req.body?.prompt || '').trim().slice(0, 4000); + if (prompt.length < 4) return res.status(400).json({ error: 'Bitte zuerst einen aussagekräftigen Prompt eingeben.' }); + const settings = getSettings(); + const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); + const model = String(req.body?.model || '').trim() || undefined; + const goal = ['precise','product','creative','cinematic'].includes(req.body?.goal) ? req.body.goal : 'product'; + const preserveSubject = req.body?.preserve_subject !== false; + const avoidText = req.body?.avoid_text !== false; + const goalLabels = { precise: 'präzise und technisch eindeutig', product: 'hochwertige Produkt- und Marketplace-Fotografie', creative: 'kreativ, aufmerksamkeitsstark und visuell eigenständig', cinematic: 'cinematisch, atmosphärisch und fotografisch hochwertig' }; + const systemPrompt = `Du bist Vendoo Prompt Director für FLUX-Bildgenerierung. Optimiere einen vorhandenen Bildprompt, ohne die eigentliche Produktidentität oder Nutzerabsicht zu verfälschen. Ziel: ${goalLabels[goal]}. ${preserveSubject ? 'Motiv, Produktmerkmale, Farben, Anzahl und Perspektiven dürfen nicht frei erfunden oder ausgetauscht werden.' : ''} ${avoidText ? 'Vermeide sichtbare Schrift, Logos, Wasserzeichen und erfundene Marken, sofern der Nutzer sie nicht ausdrücklich verlangt.' : ''} Ergänze nur sinnvolle Angaben zu Motiv, Komposition, Kamera, Licht, Materialwirkung, Hintergrund und Qualitätsmerkmalen. Gib ausschließlich valides JSON aus: {"improved_prompt":"...","notes":"..."}. Der verbesserte Prompt soll direkt in FLUX nutzbar sein und maximal 1800 Zeichen enthalten.`; + const userPrompt = `Originalprompt: +${prompt} + +Gewünschtes Profil: ${goal}. Formuliere den verbesserten Prompt bevorzugt in klarem, natürlichem Englisch, behalte aber ausdrücklich verlangte deutsche Texte unverändert bei.`; + const raw = await generateText(provider, systemPrompt, userPrompt, model); + let parsed = null; + try { parsed = JSON.parse(raw); } catch { + const match = raw.match(/\{[\s\S]*\}/); + if (match) try { parsed = JSON.parse(match[0]); } catch {} + } + const improved = String(parsed?.improved_prompt || raw || '').trim().slice(0, 2000); + if (!improved) throw new Error('Die AI hat keinen verbesserten Prompt zurückgegeben.'); + res.json({ improved_prompt: improved, notes: String(parsed?.notes || '').trim().slice(0, 500), provider, model: model || null }); + } catch (error) { + res.status(500).json({ error: error.message || 'Prompt konnte nicht verbessert werden.' }); + } +}); + +app.post('/api/flux-studio/images', async (req, res) => { + try { + const imagePath = String(req.body?.image_path || '').trim(); + if (!/^ai-generated\/[a-z0-9-]+\.(png|jpe?g|webp)$/i.test(imagePath)) { + return res.status(400).json({ error: 'Ungültiger FLUX-Bildpfad.' }); + } + const absolute = resolve(UPLOADS_DIR, imagePath); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (!absolute.startsWith(imageRoot + sep) || !existsSync(absolute)) return res.status(400).json({ error: 'Bilddatei wurde nicht gefunden.' }); + const metadata = await sharp(absolute).metadata(); + const item = createFluxImage({ + prompt: req.body?.prompt || 'Nachbearbeitete FLUX-Ausgabe', + image_path: imagePath, + width: metadata.width || req.body?.width, + height: metadata.height || req.body?.height, + seed: req.body?.seed, + steps: req.body?.steps, + style: req.body?.style, + edited_from: req.body?.edited_from, + final_prompt: req.body?.final_prompt, + profile: req.body?.profile, + duration_ms: req.body?.duration_ms, + favorite: req.body?.favorite, + job_id: req.body?.job_id, + variant_index: req.body?.variant_index, + prompt_mode: req.body?.prompt_mode, + parameters: req.body?.parameters, + }); + res.status(201).json({ ...item, public_url: `/uploads/${item.image_path}` }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gespeichert werden.' }); } +}); + +app.post('/api/flux-studio/images/:id/copy-to-generator', (req, res) => { + try { + const item = getFluxImage(req.params.id); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + const source = resolve(UPLOADS_DIR, item.image_path); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (!source.startsWith(imageRoot + sep) || !existsSync(source)) return res.status(404).json({ error: 'FLUX-Bilddatei wurde nicht gefunden.' }); + const targetDir = resolve(UPLOADS_DIR, 'allgemein'); + mkdirSync(targetDir, { recursive: true }); + const extension = extname(source).toLowerCase() || '.png'; + const filename = `${randomUUID()}${extension}`; + copyFileSync(source, join(targetDir, filename)); + res.status(201).json({ filename: `allgemein/${filename}` }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht in den Generator übernommen werden.' }); } +}); + +app.patch('/api/flux-studio/images/:id/favorite', (req, res) => { + const item = setFluxImageFavorite(req.params.id, req.body?.favorite !== false); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + res.json({ ...item, public_url: `/uploads/${item.image_path}` }); +}); + +app.delete('/api/flux-studio/images/:id', (req, res) => { + try { + const item = deleteFluxImage(req.params.id); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + const absolute = resolve(UPLOADS_DIR, item.image_path); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (absolute.startsWith(imageRoot + sep)) { try { unlinkSync(absolute); } catch {} } + res.json({ ok: true, deleted: item.id }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/ai-model-photos/precheck', async (req, res) => { + try { + const settings = getSettings(); + const payload = normalizeListingPayload(req.body || {}); + const result = await precheckAiModelPhotos(payload, settings); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/ai-model-photos/generate', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const result = await generateAiModelPhotos({ ...payload, settings }); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: result.mode || result.precheck.mode || payload.mode || 'model', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || result.mode || payload.mode || 'model', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + }); + res.json({ ...result, generation_id: generation.id, photos, provider: result.provider || null }); + } catch (err) { + if (err.code === 'AI_MODEL_PRECHECK_FAILED') { + return res.status(400).json({ error: err.message, precheck: err.precheck || null }); + } + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/listings/:id/ai-model-photos', (req, res) => { + try { + res.json(getAiModelPhotosForListing(parseInt(req.params.id))); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- Generate --- +app.post('/api/generate', async (req, res) => { + try { + const { photos, platform: platformId, ai_provider, ai_model, language, variants, template_id, seller_notes } = req.body; + if (!photos?.length) return res.status(400).json({ error: 'Keine Fotos angegeben' }); + + const platform = platforms[platformId]; + if (!platform) return res.status(400).json({ error: `Unbekannte Plattform: ${platformId}` }); + + const settings = getSettings(); + const selectedTemplate = template_id ? getTemplate(parseInt(template_id)) : null; + const notesParts = [ + settings.seller_notes, + selectedTemplate?.seller_notes, + typeof seller_notes === 'string' ? seller_notes.slice(0, 1000) : '', + ].map(value => String(value || '').trim()).filter(Boolean); + const sellerNotes = [...new Set(notesParts)].join('\n\n'); + const templateTags = Array.isArray(selectedTemplate?.default_tags) ? selectedTemplate.default_tags : []; + const lang = language || selectedTemplate?.language || settings.default_language || 'de'; + const numVariants = Math.min(parseInt(variants) || 1, 3); + const mergeTags = (values) => { + const sourceTags = Array.isArray(values) + ? values + : typeof values === 'string' + ? values.split(',') + : []; + return [...new Set([...sourceTags, ...templateTags].map(tag => String(tag).trim()).filter(Boolean))]; + }; + + const systemPrompt = platform.buildPrompt(sellerNotes, { language: lang, variants: numVariants }); + const images = await prepareForAI(photos); + const result = await generate(ai_provider || settings.default_ai, images, systemPrompt, ai_model); + + const meta = { + brand: result.brand || null, + category: result.category || null, + size: result.size || null, + color: result.color || null, + condition: result.condition || null, + suggested_price: result.suggested_price || null, + }; + + const baseData = { + platform: platformId, + ai_provider: ai_provider || settings.default_ai, + photos, seller_notes: sellerNotes, language: lang, + }; + + if (numVariants > 1 && result.variants) { + const variantsOut = result.variants.map(v => { + const item = { ...baseData, title: v.title || '', description: v.description || '', tags: mergeTags(v.tags || v.hashtags || []), ...meta, price: meta.suggested_price }; + item.description_html = wrapInTemplate(item, platformId); + return item; + }); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + return res.json({ variants: variantsOut, meta, fees }); + } + + const listing = { ...baseData, title: result.title || '', description: result.description || '', tags: mergeTags(result.tags || result.hashtags || []), ...meta, price: meta.suggested_price }; + const descHtml = wrapInTemplate(listing, platformId); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + res.json({ listing, meta, fees, description_html: descHtml }); + } catch (err) { + console.error('Generate error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Fee calculator --- +app.get('/api/fees', (req, res) => { + const price = parseFloat(req.query.price) || 0; + const platform = req.query.platform; + if (platform) return res.json(calculateFees(platform, price)); + res.json(getAllFees(price)); +}); + + +// --- Browser extension center --- +const EXTENSION_VERSION = '1.9.0'; +const EXTENSION_TARGETS = { + chrome: { folder: join(__dirname, 'extensions', 'chrome'), package: `vendoo-link-chrome-${EXTENSION_VERSION}.zip` }, + edge: { folder: join(__dirname, 'extensions', 'edge'), package: `vendoo-link-edge-${EXTENSION_VERSION}.zip` }, + firefox: { folder: join(__dirname, 'extensions', 'firefox'), package: `vendoo-link-firefox-${EXTENSION_VERSION}.zip` }, + safari: { folder: join(__dirname, 'extensions', 'safari'), package: `vendoo-link-safari-${EXTENSION_VERSION}.zip` }, +}; + +function openLocalFolder(folderPath) { + try { + const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(command, [folderPath], { detached: true, stdio: 'ignore', windowsHide: true }); + child.unref(); + return true; + } catch { return false; } +} + +app.get('/api/extensions/info', (_req, res) => { + const browsers = {}; + for (const [id, target] of Object.entries(EXTENSION_TARGETS)) { + browsers[id] = { path: target.folder, available: existsSync(target.folder), package: target.package }; + } + res.json({ version: EXTENSION_VERSION, platform: process.platform, browsers }); +}); + +app.post('/api/extensions/prepare', (req, res) => { + const browser = String(req.body?.browser || '').toLowerCase(); + const target = EXTENSION_TARGETS[browser]; + if (!target) return res.status(400).json({ error: 'Unbekannter Browser' }); + if (!existsSync(target.folder)) return res.status(404).json({ error: 'Extension-Ordner fehlt im Paket' }); + res.json({ ok: true, browser, path: target.folder, opened: openLocalFolder(target.folder), version: EXTENSION_VERSION }); +}); + +app.get('/api/extensions/download/:browser', (req, res) => { + const browser = String(req.params.browser || '').toLowerCase(); + const target = EXTENSION_TARGETS[browser]; + if (!target) return res.status(404).json({ error: 'Unbekannter Browser' }); + const packagePath = join(__dirname, 'extensions', 'packages', target.package); + if (!existsSync(packagePath)) return res.status(404).json({ error: 'Extension-Paket fehlt' }); + res.download(packagePath, target.package); +}); + + +// --- Article Quality Center --- +app.get('/api/quality-center', (req, res) => { + try { + res.json(listQualityCenter({ + query: req.query.q || '', + status: req.query.status || 'all', + grade: req.query.grade || 'all', + page: req.query.page || 1, + pageSize: req.query.page_size || 10, + })); + } catch (error) { + res.status(500).json({ error: error.message || 'Quality Center konnte nicht geladen werden.' }); + } +}); + +app.get('/api/quality-center/:id', async (req, res) => { + try { + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: false }); + if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + report.history = getQualityHistory(req.params.id); + report.comparison = compareQualityReports(req.params.id); + res.json(report); + } catch (error) { + res.status(500).json({ error: error.message || 'Artikelqualität konnte nicht analysiert werden.' }); + } +}); + +app.post('/api/quality-center/:id/analyze', async (req, res) => { + try { + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); + if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + report.history = getQualityHistory(req.params.id); + report.comparison = compareQualityReports(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'quality.analyzed', 'listing', String(req.params.id), `Score ${report.score}`, getClientIp(req)); + res.json(report); + } catch (error) { + res.status(500).json({ error: error.message || 'Qualitätsanalyse fehlgeschlagen.' }); + } +}); + +app.post('/api/quality-center/:id/ai-suggestions', async (req, res) => { + try { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + const fields = Array.isArray(req.body?.fields) ? req.body.fields.filter(field => ['title','description','tags'].includes(field)) : ['title','description','tags']; + if (!fields.length) return res.status(400).json({ error: 'Keine verbesserbaren Felder gewählt.' }); + const settings = getSettings(); + const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); + const model = String(req.body?.model || '').trim() || undefined; + const mode = ['balanced','concise','sales','seo'].includes(req.body?.mode) ? req.body.mode : 'balanced'; + const fieldContract = { + title: fields.includes('title'), description: fields.includes('description'), tags: fields.includes('tags'), + }; + const systemPrompt = `Du bist das Vendoo Artikel Quality Center. Verbessere ausschließlich die ausdrücklich gewählten Felder eines bestehenden Gebrauchtartikel-Angebots. Erfinde keine Marke, Größe, Farbe, technischen Daten, Schäden, Echtheit, Lieferumfang oder Produkteigenschaften. Bestehende Tatsachen müssen erhalten bleiben. Profil: ${mode}. Plattform: ${listing.platform || 'allgemein'}. Gewählte Felder: ${fields.join(', ')}. Gib ausschließlich valides JSON aus: {"title":null oder "...","description":null oder "...","tags":null oder ["..."],"reasons":["..."]}. Nicht gewählte Felder müssen null sein.`; + const userPrompt = JSON.stringify({ + title: listing.title || '', description: listing.description || '', tags: listing.tags || [], brand: listing.brand || '', + category: listing.category || '', size: listing.size || '', color: listing.color || '', condition: listing.condition || '', + seller_notes: listing.seller_notes || '', constraints: fieldContract, + }, null, 2); + const raw = await generateText(provider, systemPrompt, userPrompt, model); + let parsed = null; + try { parsed = JSON.parse(raw); } catch { + const match = String(raw || '').match(/\{[\s\S]*\}/); + if (match) try { parsed = JSON.parse(match[0]); } catch {} + } + if (!parsed || typeof parsed !== 'object') throw new Error('Die AI hat keinen gültigen Verbesserungsvorschlag geliefert.'); + const suggestions = { + title: fields.includes('title') ? String(parsed.title || '').trim().slice(0, 180) : null, + description: fields.includes('description') ? String(parsed.description || '').trim().slice(0, 6000) : null, + tags: fields.includes('tags') ? [...new Set((Array.isArray(parsed.tags) ? parsed.tags : []).map(value => String(value).trim()).filter(Boolean))].slice(0, 20) : null, + reasons: (Array.isArray(parsed.reasons) ? parsed.reasons : []).map(value => String(value).trim()).filter(Boolean).slice(0, 8), + }; + res.json({ listing_id: listing.id, provider, model: model || null, mode, original: { title: listing.title, description: listing.description, tags: listing.tags }, suggestions }); + } catch (error) { + res.status(500).json({ error: error.message || 'AI-Verbesserung konnte nicht erstellt werden.' }); + } +}); + +app.post('/api/quality-center/:id/apply-suggestions', async (req, res) => { + try { + const current = getListing(req.params.id); + if (!current) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + const patch = {}; + if (req.body?.title !== undefined) patch.title = String(req.body.title || '').trim().slice(0, 180); + if (req.body?.description !== undefined) { + patch.description = String(req.body.description || '').trim().slice(0, 6000); + patch.description_html = sanitizeRichHtml(plainTextToSafeHtml(patch.description)); + } + if (req.body?.tags !== undefined) patch.tags = Array.isArray(req.body.tags) ? req.body.tags.map(value => String(value).trim()).filter(Boolean).slice(0, 20) : current.tags; + if (!Object.keys(patch).length) return res.status(400).json({ error: 'Keine bestätigten Änderungen vorhanden.' }); + const updated = updateListing(req.params.id, normalizeListingPayload(patch)); + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); + if (req.user) auditLog(req.user.id, req.user.email, 'quality.ai_applied', 'listing', String(req.params.id), Object.keys(patch).join(', '), getClientIp(req)); + res.json({ listing: updated, report }); + } catch (error) { + res.status(500).json({ error: error.message || 'Verbesserungen konnten nicht übernommen werden.' }); + } +}); + +// --- Listings CRUD --- +app.post('/api/listings', (req, res) => { + try { + const listing = createListing(normalizeListingPayload({ ...req.body, created_by: req.user?.id || null, updated_by: req.user?.id || null })); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(listing.id), listing.title, getClientIp(req)); + res.json(listing); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/listings', (req, res) => { + res.json(getListings(req.query.q, req.query.platform, req.query.status)); +}); + +app.get('/api/listings/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(listing); +}); + +app.put('/api/listings/:id', (req, res) => { + const lockValidation = validateResourceLock({ resourceType: 'listing', resourceId: req.params.id, userId: req.user.id, token: req.get('x-edit-lock') || req.body?._edit_lock }); + if (!lockValidation.valid) return res.status(409).json({ error: `${lockValidation.lock?.user_name || lockValidation.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code: 'EDIT_LOCKED', lock: lockValidation.lock }); + const payload = { ...req.body }; + delete payload._edit_lock; + const listing = updateListing(req.params.id, normalizeListingPayload({ ...payload, updated_by: req.user?.id || null })); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', req.params.id, listing.title, getClientIp(req)); + res.json(listing); +}); + +app.delete('/api/listings/:id', (req, res) => { + if (req.user) auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, getClientIp(req)); + softDeleteListing(req.params.id); + res.json({ ok: true }); +}); + +// --- Duplicate --- +app.post('/api/listings/:id/duplicate', (req, res) => { + const copy = duplicateListing(req.params.id); + if (!copy) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(copy.id), copy.title, getClientIp(req)); + res.json(copy); +}); + +// --- Trash --- +app.get('/api/trash', (_req, res) => { + res.json(getDeletedListings()); +}); + +app.get('/api/trash/count', (_req, res) => { + res.json({ count: getTrashCount() }); +}); + +app.post('/api/trash/:id/restore', (req, res) => { + restoreListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash/:id', (req, res) => { + permanentlyDeleteListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash', (req, res) => { + const deleted = getDeletedListings(); + for (const l of deleted) permanentlyDeleteListing(l.id); + if (req.user) auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${deleted.length} Einträge`, getClientIp(req)); + res.json({ ok: true, deleted: deleted.length }); +}); + +// --- Templates CRUD --- +app.get('/api/templates', (_req, res) => res.json(getTemplates())); +app.post('/api/templates', (req, res) => res.json(createTemplate(req.body))); +app.put('/api/templates/:id', (req, res) => { + const t = updateTemplate(req.params.id, req.body); + if (!t) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(t); +}); +app.delete('/api/templates/:id', (req, res) => { + deleteTemplate(req.params.id); + res.json({ ok: true }); +}); + +// --- Inventory / Storage --- +app.get('/api/inventory/locations', (_req, res) => res.json(getStorageLocations())); +app.get('/api/inventory/unassigned', (_req, res) => res.json(getUnassignedListings())); +app.get('/api/inventory/location/:loc', (req, res) => res.json(getListingsByLocation(req.params.loc))); +app.put('/api/inventory/bulk-location', (req, res) => { + const { ids, location } = req.body; + if (!ids?.length) return res.status(400).json({ error: 'ids required' }); + const count = bulkUpdateLocation(ids, location || ''); + res.json({ ok: true, updated: count }); +}); + +// --- CSV Export --- +app.get('/api/export/csv', (req, res) => { + const listings = getListingsForExport(req.query.platform, req.query.status); + const headers = ['ID', 'Plattform', 'Status', 'Titel', 'Beschreibung', 'Tags', 'Marke', 'Kategorie', 'Groesse', 'Farbe', 'Zustand', 'Preis', 'Erstellt']; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + const rows = listings.map(l => [ + l.id, l.platform, l.status || 'active', l.title, l.description, + (l.tags || []).join('; '), l.brand, l.category, l.size, l.color, + l.condition, l.price, l.created_at, + ].map(escape).join(',')); + const csv = [headers.join(','), ...rows].join('\n'); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename=vendoo-export.csv'); + res.send('' + csv); +}); + +// --- Settings --- +app.get('/api/settings', (_req, res) => { + const settings = getSettings(); + settings.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + settings.has_openai_key = !!process.env.OPENAI_API_KEY; + settings.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + settings.has_etsy_key = !!process.env.ETSY_API_KEY; + settings.etsy_connected = etsyApi.isConnected(); + settings.has_ebay_credentials = ebayApi.hasCredentials(); + settings.ebay_connected = ebayApi.isConnected(); + settings.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + settings.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + settings.public_base_url = process.env.PUBLIC_BASE_URL || ''; + settings.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + settings.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + settings.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui'; + settings.ai_model_photo_local_enabled = settings.ai_model_photo_local_enabled || '1'; + settings.ai_model_photo_local_require_token = settings.ai_model_photo_local_require_token || '1'; + settings.ai_model_photo_use_source_reference = settings.ai_model_photo_use_source_reference || '1'; + settings.ai_model_photo_reference_strength = settings.ai_model_photo_reference_strength || '70'; + settings.ai_model_photo_local_resolution = settings.ai_model_photo_local_resolution || '768'; + settings.ai_model_photo_preserve_logos = settings.ai_model_photo_preserve_logos || '1'; + settings.flux_runtime_profile = settings.flux_runtime_profile || 'auto'; + settings.flux_default_profile = settings.flux_default_profile || 'balanced'; + settings.flux_prompt_assistant = settings.flux_prompt_assistant || '1'; + settings.flux_default_style = settings.flux_default_style || 'photorealistic'; + settings.flux_default_format = settings.flux_default_format || '768x768'; + settings.flux_default_variants = settings.flux_default_variants || '1'; + settings.flux_seed_lock = settings.flux_seed_lock || '0'; + settings.flux_max_parallel_jobs = settings.flux_max_parallel_jobs || '1'; + settings.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; + res.json(settings); +}); + +app.put('/api/settings', requireRole('admin'), (req, res) => { + const { anthropic_key, openai_key, openrouter_key, etsy_key, ebay_client_id, ebay_client_secret, ebay_ru_name, ebay_sandbox, ollama_url, public_base_url, ai_model_photo_local_token, ai_model_photo_local_url, ai_model_photo_local_workflow_path, ai_model_photo_local_install_path, ...dbSettings } = req.body; + let envContent = readRuntimeConfig(); + + if (anthropic_key !== undefined) { envContent = setConfigValue(envContent, 'ANTHROPIC_API_KEY', anthropic_key); process.env.ANTHROPIC_API_KEY = anthropic_key; } + if (openai_key !== undefined) { envContent = setConfigValue(envContent, 'OPENAI_API_KEY', openai_key); process.env.OPENAI_API_KEY = openai_key; } + if (openrouter_key !== undefined) { envContent = setConfigValue(envContent, 'OPENROUTER_API_KEY', openrouter_key); process.env.OPENROUTER_API_KEY = openrouter_key; } + if (etsy_key !== undefined) { envContent = setConfigValue(envContent, 'ETSY_API_KEY', etsy_key); process.env.ETSY_API_KEY = etsy_key; } + if (ebay_client_id !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_ID', ebay_client_id); process.env.EBAY_CLIENT_ID = ebay_client_id; } + if (ebay_client_secret !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_SECRET', ebay_client_secret); process.env.EBAY_CLIENT_SECRET = ebay_client_secret; } + if (ebay_ru_name !== undefined) { envContent = setConfigValue(envContent, 'EBAY_RU_NAME', ebay_ru_name); process.env.EBAY_RU_NAME = ebay_ru_name; } + if (ebay_sandbox !== undefined) { envContent = setConfigValue(envContent, 'EBAY_SANDBOX', ebay_sandbox); process.env.EBAY_SANDBOX = ebay_sandbox; } + if (ollama_url !== undefined) { envContent = setConfigValue(envContent, 'OLLAMA_URL', ollama_url); process.env.OLLAMA_URL = ollama_url; } + if (ai_model_photo_local_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_TOKEN', ai_model_photo_local_token); process.env.LOCAL_IMAGE_TOKEN = ai_model_photo_local_token; } + if (ai_model_photo_local_url !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_URL', String(ai_model_photo_local_url || '').trim().replace(/\/$/, '')); process.env.LOCAL_IMAGE_URL = String(ai_model_photo_local_url || '').trim().replace(/\/$/, ''); } + if (ai_model_photo_local_workflow_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_WORKFLOW_PATH', ai_model_photo_local_workflow_path); process.env.LOCAL_IMAGE_WORKFLOW_PATH = ai_model_photo_local_workflow_path; } + if (ai_model_photo_local_install_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_INSTALL_PATH', ai_model_photo_local_install_path); process.env.LOCAL_IMAGE_INSTALL_PATH = ai_model_photo_local_install_path; } + if (dbSettings.ai_model_photo_local_enabled !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_ENABLED', String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_ENABLED = String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'; } + if (dbSettings.ai_model_photo_local_require_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_REQUIRE_TOKEN', String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_REQUIRE_TOKEN = String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'; } + if (public_base_url !== undefined) { + const cleanPublicBaseUrl = String(public_base_url || '').trim().replace(/\/$/, ''); + envContent = setConfigValue(envContent, 'PUBLIC_BASE_URL', cleanPublicBaseUrl); + process.env.PUBLIC_BASE_URL = cleanPublicBaseUrl; + } + + writeRuntimeConfig(envContent); + if (req.user) auditLog(req.user.id, req.user.email, 'settings.updated', null, null, null, getClientIp(req)); + const result = updateSettings(dbSettings); + result.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + result.has_openai_key = !!process.env.OPENAI_API_KEY; + result.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + result.has_etsy_key = !!process.env.ETSY_API_KEY; + result.etsy_connected = etsyApi.isConnected(); + result.has_ebay_credentials = ebayApi.hasCredentials(); + result.ebay_connected = ebayApi.isConnected(); + result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + result.public_base_url = process.env.PUBLIC_BASE_URL || ''; + result.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || result.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + result.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || result.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + result.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || result.ai_model_photo_local_install_path || 'local-ai/comfyui'; + result.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; + res.json(result); +}); + +// --- HTML Templates --- +app.get('/api/html-templates', (_req, res) => res.json(getHtmlTemplates())); + +app.post('/api/html-wrap', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const html = wrapInTemplate(listing, platform || listing.platform); + res.json({ html }); +}); + +// Live rendering for the generator and listing editor. No data is persisted here. +app.post('/api/html-render', (req, res) => { + try { + const listing = normalizeListingPayload(req.body?.listing || req.body || {}); + const platform = req.body?.platform || listing.platform || 'vinted'; + res.json({ html: wrapInTemplate(listing, platform) }); + } catch (err) { + res.status(400).json({ error: err.message || 'HTML konnte nicht gerendert werden' }); + } +}); + +// --- Publishing Hardening 1.29.0 --- +app.get('/api/publishing/capabilities', (_req, res) => { + res.json({ + ...getPublishCapabilities(), + connections: { + ebay: { connected: ebayApi.isConnected(), configured: ebayApi.hasCredentials(), sandbox: process.env.EBAY_SANDBOX === 'true' }, + etsy: { connected: etsyApi.isConnected(), configured: Boolean(process.env.ETSY_API_KEY) }, + extension: { version: '1.9.0', platforms: ['vinted', 'ebay-ka'] }, + }, + }); +}); + +app.get('/api/publishing/jobs', (req, res) => { + res.json(paginatePublishingJobs({ + page: req.query.page, + pageSize: req.query.page_size, + status: req.query.status || '', + platform: req.query.platform || '', + q: req.query.q || '', + })); +}); + +app.get('/api/publishing/jobs/:id', (req, res) => { + const job = getPublishingJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Publishing-Auftrag nicht gefunden' }); + res.json(job); +}); + +app.post('/api/publishing/jobs', (req, res) => { + try { + const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : [req.body?.listing_id]; + const platform = req.body?.platform || null; + const action = req.body?.action || 'publish'; + const results = []; + for (const listingId of listingIds.filter(Boolean)) { + results.push(enqueuePublishingJob({ listingId, platform, action, force: Boolean(req.body?.force), payload: req.body?.payload || {} })); + } + res.status(results.some(item => !item.duplicate) ? 201 : 200).json({ jobs: results.map(item => item.job), duplicates: results.filter(item => item.duplicate).length }); + } catch (error) { + const status = error.code === 'DUPLICATE_PUBLISH' ? 409 : 400; + res.status(status).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +app.post('/api/publishing/jobs/:id/retry', (req, res) => { + try { res.json(retryPublishingJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.post('/api/publishing/jobs/:id/cancel', (req, res) => { + try { res.json(cancelPublishingJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.post('/api/publishing/jobs/:id/action', (req, res) => { + try { + const source = getPublishingJob(req.params.id); + if (!source) return res.status(404).json({ error: 'Auftrag nicht gefunden' }); + const action = req.body?.action; + if (!['sync', 'update', 'end'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion' }); + const result = enqueuePublishingJob({ + listingId: source.listing_id, + platform: source.platform, + action, + force: true, + payload: { offerId: source.offer_id, externalId: source.external_id }, + }); + res.status(201).json(result.job); + } catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.delete('/api/publishing/jobs', (req, res) => { + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids : String(req.query.ids || '').split(',').filter(Boolean); + res.json({ deleted: deletePublishingJobs(ids) }); + } catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.get('/api/publishing/events', (req, res) => { + res.json(listPublishingEvents({ limit: req.query.limit, level: req.query.level || '', platform: req.query.platform || '' })); +}); + +app.post('/api/extension/publishing/claim', (req, res) => { + const job = claimExtensionJob({ platform: req.body?.platform || 'vinted', clientId: req.body?.client_id || 'vendoo-link' }); + if (!job) return res.status(204).end(); + res.json(job); +}); + +app.post('/api/extension/publishing/:id/complete', (req, res) => { + try { res.json(completeExtensionJob(req.params.id, req.body || {})); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +// --- Publishing --- +const PUBLISH_URLS = { + vinted: 'https://www.vinted.de/items/new', + 'ebay-ka': 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', + 'ebay-de': 'https://www.ebay.de/sell/create', +}; + +app.get('/api/publish/info/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const logs = getPublishLogs(listing.id); + const platform = listing.platform; + const publishUrl = PUBLISH_URLS[platform] || null; + const canApiPublish = (platform === 'etsy' && etsyApi.isConnected()) || (platform === 'ebay-de' && ebayApi.isConnected()); + res.json({ listing, logs, publishUrl, canApiPublish }); +}); + +app.post('/api/publish/smart-copy', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const targetPlatform = platform || listing.platform; + const publishUrl = PUBLISH_URLS[targetPlatform] || null; + createPublishLog(listing_id, targetPlatform, 'copied'); + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : listing.tags; + res.json({ + publishUrl, + fields: { + title: listing.title, + description: listing.description, + tags, + price: listing.price || '', + brand: listing.brand || '', + category: listing.category || '', + size: listing.size || '', + color: listing.color || '', + condition: listing.condition || '', + }, + photos: listing.photos, + }); +}); + +app.post('/api/publish/mark', (req, res) => { + const { listing_id, platform, status, external_id, external_url } = req.body; + const target = platform || 'unknown'; + const activeJob = db.prepare(`SELECT id FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND mode = 'extension' + AND status IN ('awaiting_user','running') ORDER BY created_at LIMIT 1`).get(listing_id, target); + if (activeJob) { + const job = completeExtensionJob(activeJob.id, { status: status || 'published', externalId: external_id || null, externalUrl: external_url || null }); + return res.json({ ok: true, job }); + } + createPublishLog(listing_id, target, status || 'published', external_id || null, external_url || null); + res.json({ ok: true, job: null }); +}); + +app.get('/api/publish/status', (req, res) => { + const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); + res.json(getPublishStatus(ids)); +}); + +app.post('/api/publish/batch-photos', async (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); + const allPhotos = []; + for (const id of listing_ids) { + const listing = getListing(id); + if (listing?.photos) { + for (const p of listing.photos) allPhotos.push(p); + } + } + res.json({ photos: allPhotos }); +}); + +app.post('/api/photos/archive', (req, res) => { + const requested = Array.isArray(req.body?.photos) ? req.body.photos : []; + const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : []; + const photos = [...requested]; + for (const id of listingIds) { + const listing = getListing(Number(id)); + if (listing?.photos?.length) photos.push(...listing.photos); + } + + const unique = [...new Set(photos.map(photo => String(photo || '').replace(/\\/g, '/')).filter(Boolean))]; + if (unique.length < 2) return res.status(400).json({ error: 'Für ein ZIP werden mindestens zwei Fotos benötigt' }); + + const uploadRoot = resolve(UPLOADS_DIR); + const files = []; + for (const [index, relative] of unique.entries()) { + const absolute = resolve(uploadRoot, relative); + if (absolute !== uploadRoot && !absolute.startsWith(uploadRoot + sep)) continue; + if (!existsSync(absolute) || !statSync(absolute).isFile()) continue; + const originalName = basename(relative) || `foto-${index + 1}.jpg`; + const prefix = String(index + 1).padStart(2, '0'); + files.push({ name: `${prefix}-${originalName}`, data: readFileSync(absolute) }); + } + + if (files.length < 2) return res.status(404).json({ error: 'Nicht genügend Fotodateien gefunden' }); + const archive = createZip(files); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-fotos-${date}.zip`); + res.setHeader('Content-Length', archive.length); + res.send(archive); +}); + +// --- Etsy OAuth --- +app.get('/auth/etsy', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + const url = etsyApi.getAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte ETSY_API_KEY in Einstellungen setzen.`); + } +}); + +app.get('/auth/etsy/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + await etsyApi.handleCallback(code, state, redirectUri); + res.send('

                          Etsy verbunden!

                          Du kannst dieses Fenster schließen.

                          '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/etsy/status', (_req, res) => { + res.json({ connected: etsyApi.isConnected(), hasApiKey: !!process.env.ETSY_API_KEY }); +}); + +app.post('/api/publish/etsy', (req, res) => { + try { + const result = enqueuePublishingJob({ listingId: req.body?.listing_id, platform: 'etsy', action: 'publish', force: Boolean(req.body?.force) }); + res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); + } catch (error) { + res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +// --- Extension Queue --- +app.get('/api/extension/queue', (req, res) => { + const platform = req.query.platform || 'vinted'; + const jobRows = db.prepare(`SELECT j.id AS publishing_job_id, j.status AS publishing_job_status, l.* + FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id + WHERE j.platform = ? AND j.mode = 'extension' AND j.status = 'awaiting_user' + ORDER BY j.created_at`).all(platform).map(row => { + try { row.photos = JSON.parse(row.photos || '[]'); } catch { row.photos = []; } + try { row.tags = JSON.parse(row.tags || '[]'); } catch { row.tags = []; } + return row; + }); + const all = getListings(null, platform, 'active'); + const queuedIds = new Set(jobRows.map(row => row.id)); + const ids = all.map(l => l.id); + const pubStatus = getPublishStatus(ids); + const fallback = all.filter(l => { + if (queuedIds.has(l.id)) return false; + const ps = pubStatus[l.id]; + return !ps || !ps[platform] || ps[platform] === 'pending' || ps[platform] === 'copied'; + }); + const published = all.filter(l => pubStatus[l.id]?.[platform] === 'published'); + res.json({ queue: [...jobRows, ...fallback], published, total: all.length, hardened_jobs: jobRows.length }); +}); + +app.get('/api/extension/listing/:id/photos', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const baseUrl = resolvePublicBaseUrl(req); + const photoUrls = (listing.photos || []).map(filename => `${baseUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`); + res.json({ photos: photoUrls, listing }); +}); + +// --- eBay OAuth --- +app.get('/auth/ebay', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + const url = ebayApi.getOAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte eBay Credentials in Einstellungen setzen.`); + } +}); + +app.get('/auth/ebay/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + await ebayApi.handleCallback(code, state, redirectUri); + res.send('

                          eBay verbunden!

                          Du kannst dieses Fenster schließen.

                          '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/ebay/status', (_req, res) => { + res.json({ + connected: ebayApi.isConnected(), + hasCredentials: ebayApi.hasCredentials(), + sandbox: process.env.EBAY_SANDBOX === 'true', + }); +}); + +app.get('/api/ebay/policies', async (_req, res) => { + try { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + const policies = await ebayApi.getPolicies(); + res.json(policies); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// --- eBay Queue --- +app.get('/api/ebay/queue', (_req, res) => { + res.json(getEbayQueue()); +}); + +app.post('/api/ebay/queue', (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); + const added = addToEbayQueue(listing_ids); + res.json({ added: added.length, queue: getEbayQueue() }); +}); + +app.delete('/api/ebay/queue/:id', (req, res) => { + removeFromEbayQueue(req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/ebay/queue', (req, res) => { + clearEbayQueue(req.query.status || null); + res.json({ ok: true }); +}); + +app.post('/api/ebay/publish/:id', (req, res) => { + try { + const result = enqueuePublishingJob({ listingId: req.params.id, platform: 'ebay-de', action: 'publish', force: Boolean(req.body?.force) }); + res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); + } catch (error) { + res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +app.post('/api/ebay/queue/process', async (_req, res) => { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + + const pending = getEbayQueue('pending'); + const results = []; + + for (const item of pending) { + updateEbayQueue(item.id, { status: 'uploading' }); + try { + const listing = getListing(item.listing_id); + if (!listing) { updateEbayQueue(item.id, { status: 'failed', error: 'Listing nicht gefunden' }); continue; } + + const sku = listing.sku || `AL-${listing.id}`; + const result = await ebayApi.createAndPublish(listing, sku); + + updateEbayQueue(item.id, { status: 'published', ebay_item_id: result.listingId, ebay_url: result.url }); + createPublishLog(listing.id, 'ebay-de', 'published', result.listingId, result.url); + results.push({ id: item.id, listing_id: item.listing_id, status: 'published', url: result.url }); + } catch (err) { + console.error(`eBay queue item ${item.id} failed:`, err); + updateEbayQueue(item.id, { status: 'failed', error: err.message, retries: (item.retries || 0) + 1 }); + results.push({ id: item.id, listing_id: item.listing_id, status: 'failed', error: err.message }); + } + } + + res.json({ processed: results.length, results, queue: getEbayQueue() }); +}); + +app.post('/api/ebay/queue/retry', (_req, res) => { + const failed = getEbayQueue('failed'); + for (const item of failed) { + updateEbayQueue(item.id, { status: 'pending', error: null }); + } + res.json({ retried: failed.length, queue: getEbayQueue() }); +}); + +// --- Categories --- +app.get('/api/categories/:platform', (req, res) => { + const tree = getCategories(req.params.platform); + const flat = getCategoriesFlat(req.params.platform); + res.json({ tree, flat }); +}); + +app.get('/api/categories/:platform/match', (req, res) => { + const q = req.query.q || ''; + res.json(matchCategories(req.params.platform, q)); +}); + +// --- Studio Flow Dashboard --- +app.get('/api/dashboard/workflow', (req, res) => { + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 12) : 3; + res.json(getDashboardWorkflow(limit)); +}); + +app.get('/api/dashboard/analytics', (req, res) => { + const days = Number.parseInt(req.query.days, 10) || 30; + res.json(getDashboardAnalytics(days)); +}); + +// --- Dashboard Stats --- +app.get('/api/dashboard/stats', (_req, res) => { + const listings = getListings(); + const total = listings.length; + const active = listings.filter(l => l.status === 'active').length; + const sold = listings.filter(l => l.status === 'sold').length; + const reserved = listings.filter(l => l.status === 'reserved').length; + + const totalValue = listings.reduce((s, l) => s + (l.price || 0), 0); + const soldValue = listings.filter(l => l.status === 'sold').reduce((s, l) => s + (l.price || 0), 0); + + const platforms = {}; + for (const l of listings) { + platforms[l.platform] = (platforms[l.platform] || 0) + 1; + } + + const now = new Date(); + const timeline = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().slice(0, 10); + const count = listings.filter(l => l.created_at && l.created_at.startsWith(dateStr)).length; + timeline.push({ date: dateStr, label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }), count }); + } + + const recent = listings.slice(0, 5).map(l => ({ + id: l.id, title: l.title, platform: l.platform, + price: l.price, sku: l.sku, status: l.status, + photo: l.photos?.[0] || null, + created_at: l.created_at, + })); + + res.json({ total, active, sold, reserved, totalValue, soldValue, platforms, timeline, recent }); +}); + +// --- Operations Center 1.30.0: Backup, Restore, Updates & Extension diagnostics --- +app.get('/api/operations/status', requireRole('admin'), (_req, res) => { + try { res.json(getOperationsStatus()); } + catch (error) { res.status(500).json({ error: error.message || 'Operationsstatus konnte nicht geladen werden.' }); } +}); + +app.get('/api/operations/settings', requireRole('admin'), (_req, res) => { + try { res.json(getOperationsStatus().settings); } + catch (error) { res.status(500).json({ error: error.message || 'Betriebseinstellungen konnten nicht geladen werden.' }); } +}); + +app.put('/api/operations/settings', requireRole('admin'), (req, res) => { + try { + const settings = updateOperationSettings(req.body || {}); + if (req.user) auditLog(req.user.id, req.user.email, 'operations.settings.updated', 'system', null, JSON.stringify(settings), getClientIp(req)); + res.json(settings); + } catch (error) { res.status(400).json({ error: error.message || 'Betriebseinstellungen konnten nicht gespeichert werden.' }); } +}); + +app.get('/api/operations/backups', requireRole('admin'), (_req, res) => { + try { res.json({ backups: listBackups() }); } + catch (error) { res.status(500).json({ error: error.message || 'Backups konnten nicht geladen werden.' }); } +}); + +app.post('/api/operations/backups', requireRole('admin'), async (req, res) => { + try { + const result = await createBackup({ + kind: req.body?.kind || 'manual', + includeUploads: req.body?.include_uploads !== false, + includeConfig: req.body?.include_config === true, + label: req.body?.label || '', + }); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.created', 'backup', result.id, JSON.stringify({ filename: result.filename, sha256: result.sha256 }), getClientIp(req)); + res.status(201).json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } +}); + +app.get('/api/operations/backups/:id/download', requireRole('admin'), (req, res) => { + try { + const row = getBackupPath(req.params.id); + res.download(row.file_path, row.file_name); + } catch (error) { res.status(404).json({ error: error.message || 'Backup wurde nicht gefunden.' }); } +}); + +app.post('/api/operations/backups/:id/verify', requireRole('admin'), (req, res) => { + try { + const result = verifyStoredBackup(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.verified', 'backup', req.params.id, JSON.stringify({ ok: result.ok, errors: result.errors }), getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht geprüft werden.' }); } +}); + +app.delete('/api/operations/backups/:id', requireRole('admin'), (req, res) => { + try { + const result = deleteBackup(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.deleted', 'backup', req.params.id, null, getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/operations/restore/stage', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Backup-ZIP ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Für die sichere Wiederherstellung wird ein Vendoo-ZIP-Backup benötigt.'); + const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); + if (req.user) auditLog(req.user.id, req.user.email, 'restore.staged', 'system', result.id, JSON.stringify({ safety_backup_id: result.safety_backup_id }), getClientIp(req)); + res.status(202).json({ ...result, message: 'Wiederherstellung geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.' }); + } catch (error) { res.status(400).json({ error: error.message || 'Wiederherstellung konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +app.post('/api/operations/updates/check', requireRole('admin'), async (_req, res) => { + try { res.json(await checkForUpdates()); } + catch (error) { res.status(400).json({ error: error.message || 'Updateprüfung fehlgeschlagen.' }); } +}); + +app.post('/api/operations/updates/stage', requireRole('admin'), operationsUpload.single('update'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Kein Update-ZIP ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Vendoo-Updates müssen als ZIP hochgeladen werden.'); + const result = await stageUpdate(readFileSync(req.file.path), req.file.originalname); + if (req.user) auditLog(req.user.id, req.user.email, 'update.staged', 'system', result.id, JSON.stringify({ from: result.version_from, to: result.version_to, safety_backup_id: result.safety_backup_id }), getClientIp(req)); + res.status(202).json({ ...result, message: `Update ${result.version_to} geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.` }); + } catch (error) { res.status(400).json({ error: error.message || 'Update konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +app.post('/api/operations/pending/cancel', requireRole('admin'), (req, res) => { + try { + const result = cancelPendingOperation(); + if (req.user) auditLog(req.user.id, req.user.email, 'operation.cancelled', 'system', result.pending?.id || null, JSON.stringify(result.pending || {}), getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Ausstehende Operation konnte nicht abgebrochen werden.' }); } +}); + +app.get('/api/extensions/diagnostics', requireRole('admin'), (_req, res) => { + try { res.json(getExtensionDiagnostics()); } + catch (error) { res.status(500).json({ error: error.message || 'Extension-Diagnose konnte nicht geladen werden.' }); } +}); + +app.post('/api/extensions/heartbeat', (req, res) => { + try { + res.json(recordExtensionHeartbeat({ + clientId: req.body?.client_id, + browser: req.body?.browser, + version: req.body?.version, + platform: req.body?.platform, + capabilities: req.body?.capabilities, + serverUrl: req.body?.server_url, + userAgent: req.get('user-agent') || '', + })); + } catch (error) { res.status(400).json({ error: error.message || 'Extension-Heartbeat fehlgeschlagen.' }); } +}); + +// Compatibility route: creates a verified database-only ZIP instead of copying an open SQLite file. +app.get('/api/backup', requireRole('admin'), async (req, res) => { + try { + const backup = await createBackup({ kind: 'database', includeUploads: false, includeConfig: false, label: 'datenbank' }); + const row = getBackupPath(backup.id); + res.download(row.file_path, row.file_name); + } catch (error) { res.status(500).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } +}); + +app.post('/api/restore', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Backup-Datei ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Direktes Einspielen offener DB-Dateien ist gesperrt. Bitte ein verifiziertes Vendoo-ZIP-Backup verwenden.'); + const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); + res.status(202).json({ ok: true, ...result, message: 'Backup geprüft und vorbereitet. Vendoo beenden und Setup-Menüpunkt 11 ausführen.' }); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +// --- Scheduling --- +app.get('/api/schedule', (_req, res) => { + res.json(getScheduledPublishes()); +}); + +app.post('/api/schedule', (req, res) => { + const { listing_id, platform, scheduled_at } = req.body; + if (!listing_id || !scheduled_at) return res.status(400).json({ error: 'listing_id und scheduled_at erforderlich' }); + const id = schedulePublish(listing_id, platform || 'ebay-de', scheduled_at); + res.json({ ok: true, id, scheduled_at }); +}); + +app.delete('/api/schedule/:id', (req, res) => { + removeScheduledPublish(req.params.id); + res.json({ ok: true }); +}); + +// --- Price Suggestion --- +app.get('/api/price-suggest', async (req, res) => { + const { title, category, condition } = req.query; + if (!title) return res.status(400).json({ error: 'Titel erforderlich' }); + + const listings = getListings(); + const words = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); + + const similar = listings + .filter(l => l.price && l.price > 0) + .map(l => { + const lTitle = (l.title || '').toLowerCase(); + const matchCount = words.filter(w => lTitle.includes(w)).length; + const catMatch = category && l.category && l.category.toLowerCase().includes(category.toLowerCase()) ? 2 : 0; + return { ...l, score: matchCount + catMatch }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + if (!similar.length) return res.json({ suggestion: null, similar: [] }); + + const prices = similar.map(s => s.price); + const avg = prices.reduce((a, b) => a + b, 0) / prices.length; + const min = Math.min(...prices); + const max = Math.max(...prices); + + res.json({ + suggestion: Math.round(avg * 2) / 2, + min, max, avg: Math.round(avg * 100) / 100, + count: similar.length, + similar: similar.slice(0, 5).map(s => ({ title: s.title, price: s.price, platform: s.platform })), + }); +}); + +// --- Providers & Platforms --- +app.get('/api/providers', (_req, res) => res.json(getProviders())); +app.get('/api/platforms', (_req, res) => { + res.json(Object.values(platforms).map(p => ({ + id: p.id, name: p.name, fields: p.fields, + maxTitleLength: p.maxTitleLength, maxTags: p.maxTags, + }))); +}); + +// --- Scheduler Timer --- +async function processScheduledPublishes() { + const due = getDueScheduled(); + for (const item of due) { + updateScheduledPublish(item.id, { status: 'publishing', error: null }); + try { + const result = enqueuePublishingJob({ listingId: item.listing_id, platform: item.platform, action: 'publish' }); + updateScheduledPublish(item.id, { status: result.duplicate ? 'queued' : 'queued', error: result.duplicate ? 'Bereits in der Publishing-Queue' : null }); + console.log(`Scheduled publish ${item.id} → Publishing Job ${result.job.id}`); + } catch (error) { + if (error.code === 'DUPLICATE_PUBLISH') { + updateScheduledPublish(item.id, { status: 'published', error: null }); + } else { + updateScheduledPublish(item.id, { status: 'failed', error: error.message }); + } + } + } + if (due.length) console.log(`Scheduler: ${due.length} fällige Veröffentlichungen an die gehärtete Queue übergeben`); +} + +const scheduledPublishTimer = setInterval(processScheduledPublishes, 60_000); +scheduledPublishTimer.unref?.(); + +// --- Trash cleanup --- +function runTrashCleanup() { + try { + const result = cleanupTrash(30); + if (result.changes > 0) console.log(`Trash cleanup: ${result.changes} alte Einträge endgültig gelöscht`); + } catch (err) { console.error('Trash cleanup error:', err.message); } +} +const trashCleanupTimer = setInterval(runTrashCleanup, 60 * 60 * 1000); // every hour +trashCleanupTimer.unref?.(); + +// --- Start --- +if (!/^(0|false|no|off)$/i.test(String(process.env.LOCAL_IMAGE_ENABLED || 'true'))) { + try { + const settings = getSettings(); + const bootstrap = ensureLocalFluxBootstrap({ + token: process.env.LOCAL_IMAGE_TOKEN || '', + installPath: process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui', + }); + console.log(`Lokaler FLUX-Prompt-Workflow geprüft: ${bootstrap.workflow_path}`); + } catch (error) { + console.error('Lokale AI-Workflow-Selbstheilung fehlgeschlagen:', error.message || error); + } +} else { + console.log('FLUX/ComfyUI ist deaktiviert oder wird als externer Dienst betrieben.'); +} +await loadPlatforms(); +const BIND_HOST = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; +const server = app.listen(PORT, BIND_HOST, async () => { + console.log(`Vendoo ${APP_VERSION} läuft auf http://${BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST}:${PORT}`); + console.log(`Datenverzeichnis: ${runtimePathSummary().data_root}`); + if (BIND_HOST === '0.0.0.0' || BIND_HOST === '::') for (const entry of getLanIpv4Addresses()) console.log(`Vendoo im LAN: http://${entry.address}:${PORT} (${entry.name})`); + processScheduledPublishes(); + runTrashCleanup(); +}); + +function gracefulShutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal}: Vendoo wird geordnet beendet ...`); + clearInterval(scheduledPublishTimer); + clearInterval(trashCleanupTimer); + const forceTimer = setTimeout(() => { + console.error('Shutdown-Zeitlimit erreicht. Prozess wird beendet.'); + process.exit(1); + }, 10_000); + server.close(async () => { + try { await platformKernel.stop(); } catch (error) { console.error('Platform-Kernel-Shutdown fehlgeschlagen:', error.message || error); } + try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {} + try { db.close(); } catch {} + clearTimeout(forceTimer); + console.log('Vendoo wurde sauber beendet.'); + process.exit(0); + }); +} +process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.once('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup-gui.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup-gui.ps1 new file mode 100644 index 0000000..b145bd9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup-gui.ps1 @@ -0,0 +1,237 @@ +# Vendoo 1.36.0 – grafischer Zero-Edit-Installationsassistent +$ErrorActionPreference = 'Stop' +$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SetupScript = Join-Path $VendooDir 'setup.ps1' +$EnvFile = Join-Path $VendooDir '.env' +$EnvExample = Join-Path $VendooDir '.env.example' +$ModeFile = Join-Path $VendooDir '.vendoo-install-mode' + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8) +} + +function Ensure-EnvFile { + if (-not (Test-Path -LiteralPath $EnvFile)) { + if (-not (Test-Path -LiteralPath $EnvExample)) { throw '.env.example fehlt.' } + Copy-Item -LiteralPath $EnvExample -Destination $EnvFile + } +} + +function Get-EnvValue([string]$Key) { + if (-not (Test-Path -LiteralPath $EnvFile)) { return '' } + $line = Get-Content -LiteralPath $EnvFile | Where-Object { $_ -match ('^' + [regex]::Escape($Key) + '=') } | Select-Object -Last 1 + if (-not $line) { return '' } + return ($line -replace ('^' + [regex]::Escape($Key) + '='), '').Trim() +} + +function Set-EnvValue([string]$Key, [string]$Value) { + if ($Value -match "[\r\n]") { throw "Ungültiger mehrzeiliger Wert für $Key." } + Ensure-EnvFile + $content = Get-Content -LiteralPath $EnvFile -Raw + $escaped = [regex]::Escape($Key) + if ($content -match "(?m)^$escaped=") { + $content = [regex]::Replace($content, "(?m)^$escaped=.*$", "$Key=$Value") + } else { + $content = $content.TrimEnd() + "`r`n$Key=$Value`r`n" + } + Write-Utf8NoBom -Path $EnvFile -Content $content +} + +function New-SecureToken { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Test-Port([string]$Text) { + $port = 0 + return [int]::TryParse($Text, [ref]$port) -and $port -ge 1024 -and $port -le 65535 +} + +function Set-Configuration([string]$Target, [string]$Access, [int]$Port, [string]$PublicUrl, [bool]$FluxEnabled) { + Ensure-EnvFile + $token = Get-EnvValue 'VENDOO_SETUP_TOKEN' + if ([string]::IsNullOrWhiteSpace($token)) { + $token = New-SecureToken + Set-EnvValue 'VENDOO_SETUP_TOKEN' $token + } + + Set-EnvValue 'PORT' ([string]$Port) + Set-EnvValue 'VENDOO_HOST_PORT' ([string]$Port) + Set-EnvValue 'VENDOO_REQUIRE_SETUP_TOKEN' 'true' + Set-EnvValue 'VENDOO_COOKIE_SAMESITE' 'lax' + Set-EnvValue 'LOCAL_IMAGE_ENABLED' ($(if ($FluxEnabled) { 'true' } else { 'false' })) + + if ($Target -eq 'windows-local') { + Set-EnvValue 'VENDOO_BIND_HOST' ($(if ($Access -eq 'LAN') { '0.0.0.0' } else { '127.0.0.1' })) + Set-EnvValue 'VENDOO_PUBLISH_ADDRESS' '127.0.0.1' + Set-EnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-EnvValue 'VENDOO_TRUST_PROXY' 'false' + Set-EnvValue 'LOCAL_IMAGE_URL' 'http://127.0.0.1:8188' + } else { + Set-EnvValue 'VENDOO_BIND_HOST' '0.0.0.0' + Set-EnvValue 'VENDOO_PUBLISH_ADDRESS' ($(if ($Access -eq 'LAN') { '0.0.0.0' } else { '127.0.0.1' })) + Set-EnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-EnvValue 'VENDOO_TRUST_PROXY' 'false' + Set-EnvValue 'LOCAL_IMAGE_URL' 'http://host.docker.internal:8188' + } + + Set-EnvValue 'PUBLIC_BASE_URL' ($(if ([string]::IsNullOrWhiteSpace($PublicUrl)) { '' } else { $PublicUrl.Trim().TrimEnd('/') })) + + Write-Utf8NoBom -Path $ModeFile -Content ($Target + "`n") + try { [System.Windows.Forms.Clipboard]::SetText($token) } catch {} + return $token +} + +$form = New-Object System.Windows.Forms.Form +$form.Text = 'Vendoo 1.36.0 – Installation' +$form.StartPosition = 'CenterScreen' +$form.Size = New-Object System.Drawing.Size(720, 620) +$form.MinimumSize = New-Object System.Drawing.Size(720, 620) +$form.Font = New-Object System.Drawing.Font('Segoe UI', 10) +$form.BackColor = [System.Drawing.Color]::FromArgb(246, 248, 247) + +$title = New-Object System.Windows.Forms.Label +$title.Text = 'Vendoo installieren' +$title.Font = New-Object System.Drawing.Font('Segoe UI Semibold', 22) +$title.AutoSize = $true +$title.Location = New-Object System.Drawing.Point(34, 24) +$form.Controls.Add($title) + +$sub = New-Object System.Windows.Forms.Label +$sub.Text = 'Alle Pflichtwerte werden geprüft und automatisch gespeichert. Es müssen keine Dateien bearbeitet werden.' +$sub.AutoSize = $false +$sub.Size = New-Object System.Drawing.Size(640, 48) +$sub.Location = New-Object System.Drawing.Point(38, 72) +$sub.ForeColor = [System.Drawing.Color]::FromArgb(70, 80, 76) +$form.Controls.Add($sub) + +function Add-Label([string]$Text, [int]$Y) { + $l = New-Object System.Windows.Forms.Label + $l.Text = $Text + $l.AutoSize = $true + $l.Location = New-Object System.Drawing.Point(40, $Y) + $form.Controls.Add($l) + return $l +} + +Add-Label 'Installationsart' 132 | Out-Null +$target = New-Object System.Windows.Forms.ComboBox +$target.DropDownStyle = 'DropDownList' +$target.Items.AddRange(@('Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC')) +$target.SelectedIndex = 0 +$target.Location = New-Object System.Drawing.Point(40, 158) +$target.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($target) + +Add-Label 'Erreichbarkeit' 208 | Out-Null +$access = New-Object System.Windows.Forms.ComboBox +$access.DropDownStyle = 'DropDownList' +$access.Items.AddRange(@('Nur auf diesem PC (empfohlen)', 'Im privaten LAN erreichbar')) +$access.SelectedIndex = 0 +$access.Location = New-Object System.Drawing.Point(40, 234) +$access.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($access) + +Add-Label 'Port' 284 | Out-Null +$portBox = New-Object System.Windows.Forms.NumericUpDown +$portBox.Minimum = 1024 +$portBox.Maximum = 65535 +$portBox.Value = 8124 +$portBox.Location = New-Object System.Drawing.Point(40, 310) +$portBox.Size = New-Object System.Drawing.Size(160, 34) +$form.Controls.Add($portBox) + +Add-Label 'Öffentliche/LAN-Basisadresse (optional)' 358 | Out-Null +$publicUrl = New-Object System.Windows.Forms.TextBox +$publicUrl.Location = New-Object System.Drawing.Point(40, 384) +$publicUrl.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($publicUrl) + +$flux = New-Object System.Windows.Forms.CheckBox +$flux.Text = 'Lokales FLUX Studio aktivieren (ComfyUI bleibt ein getrennter Dienst)' +$flux.AutoSize = $true +$flux.Location = New-Object System.Drawing.Point(40, 438) +$form.Controls.Add($flux) + +$hint = New-Object System.Windows.Forms.Label +$hint.Text = 'AI- und Marktplatz-Schlüssel werden später bequem unter Vendoo → Einstellungen eingetragen.' +$hint.AutoSize = $false +$hint.Size = New-Object System.Drawing.Size(620, 42) +$hint.Location = New-Object System.Drawing.Point(40, 474) +$hint.ForeColor = [System.Drawing.Color]::FromArgb(70, 80, 76) +$form.Controls.Add($hint) + +$install = New-Object System.Windows.Forms.Button +$install.Text = 'Installieren' +$install.Size = New-Object System.Drawing.Size(180, 44) +$install.Location = New-Object System.Drawing.Point(480, 520) +$install.BackColor = [System.Drawing.Color]::FromArgb(32, 112, 78) +$install.ForeColor = [System.Drawing.Color]::White +$install.FlatStyle = 'Flat' +$form.Controls.Add($install) + +$advanced = New-Object System.Windows.Forms.Button +$advanced.Text = 'Konsolenmenü' +$advanced.Size = New-Object System.Drawing.Size(150, 44) +$advanced.Location = New-Object System.Drawing.Point(40, 520) +$form.Controls.Add($advanced) + +$cancel = New-Object System.Windows.Forms.Button +$cancel.Text = 'Abbrechen' +$cancel.Size = New-Object System.Drawing.Size(130, 44) +$cancel.Location = New-Object System.Drawing.Point(330, 520) +$form.Controls.Add($cancel) + +$target.Add_SelectedIndexChanged({ + if ($target.SelectedIndex -eq 1) { + $hint.Text = 'Docker Desktop muss gestartet sein. AI- und Marktplatz-Schlüssel werden später in Vendoo gepflegt.' + } else { + $hint.Text = 'AI- und Marktplatz-Schlüssel werden später bequem unter Vendoo → Einstellungen eingetragen.' + } +}) + +$advanced.Add_Click({ + $form.Hide() + Start-Process powershell.exe -ArgumentList @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $SetupScript + '"'),'menu') -Wait + $form.Close() +}) + +$cancel.Add_Click({ $form.Close() }) + +$install.Add_Click({ + try { + $install.Enabled = $false + $targetMode = if ($target.SelectedIndex -eq 1) { 'docker' } else { 'windows-local' } + $accessMode = if ($access.SelectedIndex -eq 1) { 'LAN' } else { 'LOCAL' } + $url = $publicUrl.Text.Trim() + if ($url -and $url -notmatch '^https?://') { throw 'Die Basisadresse muss mit http:// oder https:// beginnen.' } + if ($accessMode -eq 'LAN' -and [string]::IsNullOrWhiteSpace($url)) { + $answer = [System.Windows.Forms.MessageBox]::Show('Für QR-Uploads ist später eine LAN-Adresse sinnvoll. Trotzdem ohne Basisadresse fortfahren?', 'LAN-Adresse fehlt', 'YesNo', 'Warning') + if ($answer -ne 'Yes') { return } + } + $token = Set-Configuration -Target $targetMode -Access $accessMode -Port ([int]$portBox.Value) -PublicUrl $url -FluxEnabled $flux.Checked + [System.Windows.Forms.MessageBox]::Show("Die Konfiguration ist gespeichert. Der Erst-Admin-Code wurde in die Zwischenablage kopiert.`r`n`r`n$token`r`n`r`nBewahre ihn bis zur ersten Anmeldung sicher auf.", 'Vendoo vorbereitet', 'OK', 'Information') | Out-Null + $form.Hide() + $command = if ($targetMode -eq 'docker') { 'install-docker' } else { 'install-local' } + $proc = Start-Process powershell.exe -ArgumentList @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $SetupScript + '"'),$command) -Wait -PassThru + if ($proc.ExitCode -eq 0) { + Start-Process ("http://127.0.0.1:{0}/login.html" -f [int]$portBox.Value) + } else { + [System.Windows.Forms.MessageBox]::Show("Die Installation wurde mit Exit-Code $($proc.ExitCode) beendet. Das Konsolenfenster enthält die Details.", 'Installation nicht abgeschlossen', 'OK', 'Error') | Out-Null + } + $form.Close() + } catch { + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Eingabe prüfen', 'OK', 'Warning') | Out-Null + } finally { + $install.Enabled = $true + } +}) + +[void]$form.ShowDialog() diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.bat b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.bat new file mode 100644 index 0000000..12fb400 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.bat @@ -0,0 +1,47 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +title Vendoo Setup +cd /d "%~dp0" + +if not exist "%~dp0setup.ps1" ( + echo. + echo [FEHLER] setup.ps1 wurde nicht gefunden: + echo %~dp0setup.ps1 + echo. + pause + exit /b 2 +) + +set "VENDOO_SETUP_PS1=%~dp0setup.ps1" +set "VENDOO_SETUP_GUI=%~dp0setup-gui.ps1" + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$files=@($env:VENDOO_SETUP_PS1); if(Test-Path -LiteralPath $env:VENDOO_SETUP_GUI){$files += $env:VENDOO_SETUP_GUI}; foreach($file in $files){$tokens=$null; $errors=$null; [System.Management.Automation.Language.Parser]::ParseFile($file,[ref]$tokens,[ref]$errors) | Out-Null; if($errors.Count -gt 0){$errors | ForEach-Object { Write-Host ('[PARSER] ' + $file + ': ' + $_.Message) -ForegroundColor Red }; exit 12}}" +if errorlevel 1 ( + echo. + echo [FEHLER] Die PowerShell-Syntax des Installers ist ungueltig. + echo Vendoo Setup wurde aus Sicherheitsgruenden nicht gestartet. + echo. + pause + exit /b 12 +) + +if "%~1"=="" ( + if exist "%VENDOO_SETUP_GUI%" ( + powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%VENDOO_SETUP_GUI%" + exit /b %ERRORLEVEL% + ) +) + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%VENDOO_SETUP_PS1%" %* +set "VENDOO_EXIT=%ERRORLEVEL%" + +if not "%VENDOO_EXIT%"=="0" ( + echo. + echo [FEHLER] Vendoo Setup wurde mit Exit-Code %VENDOO_EXIT% beendet. + echo Die Protokolle befinden sich im Ordner "%~dp0logs". + echo. +) + +pause +exit /b %VENDOO_EXIT% diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.ps1 b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.ps1 new file mode 100644 index 0000000..e34667a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.ps1 @@ -0,0 +1,1738 @@ +$ErrorActionPreference = 'Stop' +$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Version = '1.36.0' +$Port = 8124 +$InstallModeFile = Join-Path $VendooDir '.vendoo-install-mode' + +try { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [Console]::InputEncoding = $utf8NoBom + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch {} + + +function Write-Utf8NoBomFile([string]$Path, [string]$Content) { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8) +} + +function Write-Header { + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════╗' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ║ V E N D O O S E T U P ║' -ForegroundColor Green + Write-Host ' ║ Listing-Generator v'$Version' ║' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ╚══════════════════════════════════════╝' -ForegroundColor DarkGreen + Write-Host '' +} + +function Write-Step($step, $text) { + Write-Host " [$step] " -ForegroundColor Cyan -NoNewline + Write-Host $text +} + +function Write-OK($text) { + Write-Host " [OK] " -ForegroundColor Green -NoNewline + Write-Host $text +} + +function Write-Warn($text) { + Write-Host " [!] " -ForegroundColor Yellow -NoNewline + Write-Host $text +} + +function Write-Err($text) { + Write-Host " [X] " -ForegroundColor Red -NoNewline + Write-Host $text +} + +function Remove-LegacyTryOnComponents { + $migrationMarker = Join-Path $VendooDir '.migration-image-stack-cleanup-1.20.0' + if (Test-Path -LiteralPath $migrationMarker) { return } + + $legacyRoot = Join-Path $VendooDir 'local-ai\fashn-vton' + $legacyUploadRoot = Join-Path $VendooDir 'uploads\vto-models' + $legacyArchiveRoot = Join-Path $VendooDir 'uploads\ai-generated\legacy-models' + $hasLegacyRuntime = (Test-Path -LiteralPath $legacyRoot) -or (Test-Path -LiteralPath $legacyUploadRoot) + if ($hasLegacyRuntime) { + Write-Host '' + Write-Step 'Cleanup' 'Entferne den aufgegebenen lokalen Try-on-Stack. Eigene Bilder bleiben erhalten ...' + } + + try { + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'fashn-vton|local-vto|service\.py.+8190' } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + } catch {} + try { & schtasks.exe /Delete /F /TN 'Vendoo FASHN VTON' 2>$null | Out-Null } catch {} + try { Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'VendooFashnVton' -ErrorAction SilentlyContinue } catch {} + try { + $startup = [Environment]::GetFolderPath('Startup') + if ($startup) { Remove-Item -LiteralPath (Join-Path $startup 'Vendoo-FASHN-VTON.cmd') -Force -ErrorAction SilentlyContinue } + } catch {} + + if (Test-Path -LiteralPath $legacyUploadRoot) { + Get-ChildItem -LiteralPath $legacyUploadRoot -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $relative = $_.FullName.Substring($legacyUploadRoot.Length).TrimStart('\') + $target = Join-Path $legacyArchiveRoot $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null + if (-not (Test-Path -LiteralPath $target)) { Move-Item -LiteralPath $_.FullName -Destination $target -Force -ErrorAction SilentlyContinue } + } + Remove-Item -LiteralPath $legacyUploadRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + if (Test-Path -LiteralPath $legacyRoot) { + Write-Step 'Cleanup' 'Entferne alte Python-, Modell- und Dienstdateien ...' + Remove-Item -LiteralPath $legacyRoot -Recurse -Force -ErrorAction SilentlyContinue + } + @( + 'scripts\install-local-vto.ps1','scripts\uninstall-local-vto.ps1', + 'lib\ai-vto-bfl.mjs','lib\ai-vto-local.mjs','lib\ai-vto-model-jobs.mjs','lib\ai-vto-model-prompts.mjs', + 'local-ai\workflows\vendoo_flux_tryon_api.json' + ) | ForEach-Object { Remove-Item -LiteralPath (Join-Path $VendooDir $_) -Force -ErrorAction SilentlyContinue } + Get-ChildItem -LiteralPath (Join-Path $VendooDir 'logs') -Filter 'local-vto*' -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + + $legacyEnv = Join-Path $VendooDir 'local-ai\.env.local-flux' + if (Test-Path -LiteralPath $legacyEnv) { + $cleanLines = Get-Content -LiteralPath $legacyEnv -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch '^LOCAL_IMAGE_TRYON_WORKFLOW_PATH=' } + Write-Utf8NoBomFile -Path $legacyEnv -Content (($cleanLines -join [Environment]::NewLine) + [Environment]::NewLine) + } + + $rootEnv = Join-Path $VendooDir '.env' + if (Test-Path -LiteralPath $rootEnv) { + $cleanRootEnv = Get-Content -LiteralPath $rootEnv -ErrorAction SilentlyContinue | Where-Object { + $_ -notmatch '^(BFL_API_KEY|LOCAL_IMAGE_TRYON_WORKFLOW_PATH|VENDOO_VTO_URL|VENDOO_VTO_TOKEN)=' + } + Write-Utf8NoBomFile -Path $rootEnv -Content (($cleanRootEnv -join [Environment]::NewLine) + [Environment]::NewLine) + } + + $legacyDocs = @( + 'SLICE_24_VIRTUAL_TRYON_PROMPT_POPUP.md','SLICE_24_1_TRYON_WORKFLOW_SELF_HEAL.md', + 'SLICE_25_PRODUCTIVE_VIRTUAL_TRYON.md','SLICE_25_SHA256.json','SLICE_25_VERIFICATION.json', + 'SLICE_26_LOCAL_FASHN_VTON_FLUX_PROMPT.md','SLICE_26_SHA256.json','SLICE_26_VERIFICATION.json', + 'SLICE_26_1_PYTHON_INSTALLER_LIVE_STATUS.md','SLICE_26_1_SHA256.json','SLICE_26_1_VERIFICATION.json', + 'SLICE_26_2_INSTALLER_ENCODING_PYTHON_DETECTION.md','SLICE_26_2_SHA256.json','SLICE_26_2_VERIFICATION.json', + 'SLICE_26_3_PORTABLE_PYTHON_RUNTIME.md','SLICE_26_3_SHA256.json','SLICE_26_3_VERIFICATION.json', + 'SLICE_26_4_UV_PYTHON_DETECTION.md','SLICE_26_4_SHA256.json','SLICE_26_4_VERIFICATION.json', + 'SLICE_26_5_NATIVE_PROCESS_RUNNER.md','SLICE_26_5_VERIFICATION.json', + 'SLICE_26_6_VTO_PIP_BOOTSTRAP_STATUS.md','SLICE_26_6_VERIFICATION.json', + 'SLICE_26_7_NPM_PUBLIC_REGISTRY_ATOMIC_REPAIR.md','SLICE_26_7_VERIFICATION.json', + 'SLICE_26_8_VTO_DEPENDENCY_INSTALLER.md','SLICE_26_8_VERIFICATION.json', + 'SLICE_26_9_VTO_UTF8_PARSER_AUTOSTART.md','SLICE_27_FLUX_MODEL_STUDIO_LIBRARY.md','SLICE_27_VERIFICATION.json' + ) + foreach ($doc in $legacyDocs) { Remove-Item -LiteralPath (Join-Path $VendooDir ('docs\' + $doc)) -Force -ErrorAction SilentlyContinue } + + Write-Utf8NoBomFile -Path $migrationMarker -Content "Vendoo 1.20.0 image-stack cleanup completed." + if ($hasLegacyRuntime) { Write-OK 'Alter Try-on-Stack wurde entfernt; FLUX bleibt vollständig erhalten.' } +} + + +function Test-VendooPowerShellFiles { + $relativeFiles = @( + 'scripts\install-local-flux.ps1', + 'scripts\uninstall-local-flux.ps1', + 'setup-gui.ps1' + ) + $failed = $false + foreach ($relative in $relativeFiles) { + $path = Join-Path $VendooDir $relative + if (-not (Test-Path -LiteralPath $path)) { + Write-Err ("Pflichtskript fehlt: {0}" -f $relative) + $failed = $true + continue + } + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + Write-Err ("PowerShell-Syntaxfehler in {0}" -f $relative) + foreach ($parseError in $errors) { + Write-Host (" Zeile {0}: {1}" -f $parseError.Extent.StartLineNumber, $parseError.Message) -ForegroundColor Red + } + $failed = $true + } + } + return (-not $failed) +} + +function Test-NodeInstalled { + try { + $v = & node --version 2>$null + if ($v -match '^v(\d+)') { + $major = [int]$Matches[1] + return @{ Installed = $true; Version = $v; Major = $major } + } + } catch {} + return @{ Installed = $false; Version = $null; Major = 0 } +} + +function Test-NpmInstalled { + try { + $v = & npm --version 2>$null + if ($v) { return $true } + } catch {} + return $false +} + +function Get-VendooInstallState { + $nodeModules = Test-Path (Join-Path $VendooDir 'node_modules') + $envFile = Test-Path (Join-Path $VendooDir '.env') + $dbFile = Test-Path (Join-Path $VendooDir 'db\vendoo.db') + $state = 'not-installed' + if ($nodeModules -and $envFile) { $state = 'installed' } + elseif ($nodeModules -or $envFile -or $dbFile) { $state = 'partial' } + return @{ State = $state; NodeModules = $nodeModules; EnvFile = $envFile; DbFile = $dbFile } +} + +function Get-LocalFluxScriptPath { + return Join-Path $VendooDir 'scripts\install-local-flux.ps1' +} + + +function Stop-VendooNodeProcesses { + $stopped = 0 + try { + $escapedDir = [regex]::Escape($VendooDir) + $processes = Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and $_.CommandLine -match $escapedDir } + foreach ($process in $processes) { + try { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + $stopped++ + } catch {} + } + } catch {} + if ($stopped -gt 0) { + Write-Warn "$stopped laufende Vendoo-Node-Prozess(e) beendet" + Start-Sleep -Milliseconds 750 + } +} + +function Remove-NodeModulesSafe { + $nm = Join-Path $VendooDir 'node_modules' + if (-not (Test-Path $nm)) { return $true } + + Stop-VendooNodeProcesses + Write-Step '...' 'Unvollstaendige node_modules werden bereinigt...' + + try { + Remove-Item $nm -Recurse -Force -Confirm:$false -ErrorAction Stop + } catch { + try { + & cmd.exe /d /c "rmdir /s /q `"$nm`"" | Out-Null + } catch {} + } + + if (Test-Path $nm) { + Write-Err 'node_modules konnte nicht vollstaendig entfernt werden.' + Write-Host ' Ursache ist meist ein noch laufender Node-Prozess, Virenscanner oder Explorer-Zugriff.' -ForegroundColor Yellow + Write-Host ' Bitte alle Vendoo-/Node-Fenster schliessen, notfalls Windows neu starten und Reparieren erneut ausfuehren.' -ForegroundColor Yellow + return $false + } + return $true +} + +function Repair-VendooNpmRegistryConfiguration { + $lockFile = Join-Path $VendooDir 'package-lock.json' + $npmrcFile = Join-Path $VendooDir '.npmrc' + $publicRegistry = 'https://registry.npmjs.org/' + + $npmrcContent = @( + 'registry=https://registry.npmjs.org/', + 'audit=false', + 'fund=false', + 'strict-ssl=true', + 'prefer-offline=true', + 'fetch-retries=5', + 'fetch-retry-factor=2', + 'fetch-retry-mintimeout=10000', + 'fetch-retry-maxtimeout=120000' + ) -join [Environment]::NewLine + Write-Utf8NoBomFile -Path $npmrcFile -Content ($npmrcContent + [Environment]::NewLine) + + if (-not (Test-Path -LiteralPath $lockFile)) { return $true } + try { + $raw = [System.IO.File]::ReadAllText($lockFile) + $original = $raw + $raw = [regex]::Replace( + $raw, + 'https://packages\.applied-caas-gateway\d+\.internal\.api\.openai\.org/artifactory/api/npm/npm-public/', + $publicRegistry, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if ($raw -match '(?i)internal\.api\.openai\.org|applied-caas-gateway|/artifactory/api/npm/') { + Write-Err 'package-lock.json enthält weiterhin eine nicht öffentliche Registry-URL.' + Write-Host ' Das Paket wurde aus einer Build-Umgebung verunreinigt. npm ci wird aus Sicherheitsgründen nicht gestartet.' -ForegroundColor Yellow + return $false + } + if ($raw -ne $original) { + Write-Warn 'Interne Build-Registry im package-lock.json wurde automatisch auf registry.npmjs.org korrigiert.' + Write-Utf8NoBomFile -Path $lockFile -Content $raw + } + return $true + } catch { + Write-Err ("package-lock.json konnte nicht geprüft werden: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-VendooNodeModulesBackupPath { + return (Join-Path $VendooDir 'node_modules.vendoo-backup') +} + +function Restore-VendooNodeModulesBackup { + $nodeModules = Join-Path $VendooDir 'node_modules' + $backup = Get-VendooNodeModulesBackupPath + if (-not (Test-Path -LiteralPath $backup)) { return } + try { + if (Test-Path -LiteralPath $nodeModules) { + Remove-Item -LiteralPath $nodeModules -Recurse -Force -ErrorAction SilentlyContinue + } + Move-Item -LiteralPath $backup -Destination $nodeModules -Force -ErrorAction Stop + Write-Warn 'Vorherige node_modules wurden nach dem fehlgeschlagenen npm-Lauf wiederhergestellt.' + } catch { + Write-Warn ("node_modules-Sicherung konnte nicht automatisch wiederhergestellt werden: {0}" -f $_.Exception.Message) + } +} + +function Prepare-VendooNodeModulesBackup { + $nodeModules = Join-Path $VendooDir 'node_modules' + $backup = Get-VendooNodeModulesBackupPath + Stop-VendooNodeProcesses + + if ((Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $nodeModules)) { + try { Move-Item -LiteralPath $backup -Destination $nodeModules -Force -ErrorAction Stop } catch {} + } + if (Test-Path -LiteralPath $backup) { + try { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } catch { + Write-Err 'Alte node_modules-Sicherung konnte nicht entfernt werden.' + return $false + } + } + if (Test-Path -LiteralPath $nodeModules) { + try { + Move-Item -LiteralPath $nodeModules -Destination $backup -Force -ErrorAction Stop + Write-Step '...' 'Bestehende node_modules wurden vorübergehend gesichert.' + } catch { + Write-Warn 'node_modules konnten nicht atomar gesichert werden. Vendoo versucht die robuste Bereinigung.' + if (-not (Remove-NodeModulesSafe)) { return $false } + } + } + return $true +} + +function Remove-VendooNodeModulesBackup { + $backup = Get-VendooNodeModulesBackupPath + if (Test-Path -LiteralPath $backup) { + try { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } catch { + Write-Warn 'Die alte node_modules-Sicherung konnte nicht gelöscht werden und kann später manuell entfernt werden.' + } + } +} + +function Invoke-VendooNpmInstall([switch]$ForceClean) { + $logDir = Join-Path $VendooDir 'logs' + if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $logFile = Join-Path $logDir 'last-npm-install.log' + $lockFile = Join-Path $VendooDir 'package-lock.json' + $command = if (Test-Path $lockFile) { 'ci' } else { 'install' } + + if (-not (Repair-VendooNpmRegistryConfiguration)) { return $false } + if (-not (Prepare-VendooNodeModulesBackup)) { return $false } + + $npmCommand = $null + try { $npmCommand = (Get-Command 'npm.cmd' -ErrorAction Stop).Source } catch {} + if ([string]::IsNullOrWhiteSpace([string]$npmCommand)) { + Restore-VendooNodeModulesBackup + Write-Err 'npm.cmd wurde nicht gefunden.' + return $false + } + + $attempt = 1 + $maxAttempts = 3 + while ($attempt -le $maxAttempts) { + $stdoutFile = Join-Path $logDir ('npm-install-{0}.stdout.log' -f $attempt) + $stderrFile = Join-Path $logDir ('npm-install-{0}.stderr.log' -f $attempt) + $runnerFile = Join-Path $logDir ('npm-install-{0}.cmd' -f $attempt) + Remove-Item -LiteralPath $stdoutFile,$stderrFile,$runnerFile -Force -ErrorAction SilentlyContinue + + $runnerContent = @( + '@echo off', + ('cd /d "{0}"' -f $VendooDir), + ('call "{0}" {1} --registry=https://registry.npmjs.org/ --no-audit --no-fund --loglevel=warn' -f $npmCommand, $command), + 'set "VENDOO_NPM_EXIT=%ERRORLEVEL%"', + 'exit /b %VENDOO_NPM_EXIT%' + ) -join "`r`n" + [System.IO.File]::WriteAllText($runnerFile, $runnerContent + "`r`n", [System.Text.Encoding]::ASCII) + + $process = $null + $output = @() + $exitCode = 1 + $npmTimedOut = $false + try { + $cmdArguments = ('/d /s /c ""{0}""' -f $runnerFile) + $process = Start-Process -FilePath $env:ComSpec -ArgumentList $cmdArguments -WorkingDirectory $VendooDir -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + $started = Get-Date + $lastShown = -1 + $npmTimeoutSeconds = 1800 + while (-not $process.HasExited) { + Start-Sleep -Seconds 1 + $process.Refresh() + $elapsed = [int]((Get-Date) - $started).TotalSeconds + if ($elapsed -ge $npmTimeoutSeconds) { + $npmTimedOut = $true + try { $process.Kill() } catch {} + break + } + if ($elapsed -ge 2 -and [Math]::Floor($elapsed / 2) -gt $lastShown) { + $lastShown = [Math]::Floor($elapsed / 2) + $consoleWidth = 118 + try { if ($Host.UI.RawUI.WindowSize.Width -gt 20) { $consoleWidth = [Math]::Max(40, $Host.UI.RawUI.WindowSize.Width - 1) } } catch {} + $statusText = (" npm $command läuft seit ${elapsed}s (Versuch $attempt/$maxAttempts)") + if ($statusText.Length -gt $consoleWidth) { $statusText = $statusText.Substring(0, $consoleWidth) } + Write-Host ("`r" + $statusText.PadRight($consoleWidth)) -NoNewline -ForegroundColor DarkGray + } + } + $process.WaitForExit() + $process.Refresh() + $consoleWidth = 118 + try { if ($Host.UI.RawUI.WindowSize.Width -gt 20) { $consoleWidth = [Math]::Max(40, $Host.UI.RawUI.WindowSize.Width - 1) } } catch {} + Write-Host ("`r" + (' ' * $consoleWidth) + "`r") -NoNewline + $exitCode = if ($npmTimedOut) { -2 } else { [int]$process.ExitCode } + + $stdoutLines = if (Test-Path -LiteralPath $stdoutFile) { @(Get-Content -LiteralPath $stdoutFile -ErrorAction SilentlyContinue) } else { @() } + $stderrLines = if (Test-Path -LiteralPath $stderrFile) { @(Get-Content -LiteralPath $stderrFile -ErrorAction SilentlyContinue) } else { @() } + $output = @($stdoutLines + $stderrLines) + if ($npmTimedOut) { $output += 'Vendoo hat npm nach 30 Minuten ohne Abschluss beendet.' } + + # Zweite Sicherheitsstufe: Ein echter npm-Fehler darf niemals als Exit-Code 0 durchrutschen. + $fatalOutput = @($output | Where-Object { ([string]$_) -match '(?i)^\s*npm\s+(error|ERR!)\b' }) + if ($exitCode -eq 0 -and $fatalOutput.Count -gt 0) { + $exitCode = 1 + $output += 'Vendoo-Sicherheitsprüfung: npm meldete Fehlertext trotz Exit-Code 0. Lauf wird als fehlgeschlagen gewertet.' + } + } catch { + $output = @($_.Exception.Message) + $exitCode = 1 + } + + $header = @( + ('Zeit: {0}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')), + ('Befehl: npm {0} --registry=https://registry.npmjs.org/ --no-audit --no-fund --loglevel=warn' -f $command), + ('Arbeitsordner: {0}' -f $VendooDir), + ('Versuch: {0}/{1}' -f $attempt, $maxAttempts), + ('Exit-Code: {0}' -f $exitCode), + '' + ) + Write-Utf8NoBomFile -Path $logFile -Content ((@($header + $output) -join [Environment]::NewLine) + [Environment]::NewLine) + + Remove-Item -LiteralPath $stdoutFile,$stderrFile,$runnerFile -Force -ErrorAction SilentlyContinue + + if ($exitCode -eq 0) { + $warnings = @($output | Where-Object { ([string]$_) -match '(?i)^npm\s+warn|deprecated' }) + if ($warnings.Count -gt 0) { + Write-Warn ("npm $command war erfolgreich, meldete aber $($warnings.Count) Warnung(en). Details: $logFile") + } + Remove-VendooNodeModulesBackup + Write-OK "Dependencies mit npm $command installiert" + return $true + } + + $text = ($output | Out-String) + $isNetworkError = $text -match '(?i)ETIMEDOUT|ECONNRESET|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|network request|502 Bad Gateway|503 Service Unavailable' + if ($attempt -lt $maxAttempts -and $isNetworkError) { + $waitSeconds = if ($attempt -eq 1) { 5 } else { 15 } + Write-Warn ("npm-Netzwerkfehler. Neuer Versuch in $waitSeconds Sekunden über registry.npmjs.org.") + if (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules')) { + Remove-Item -LiteralPath (Join-Path $VendooDir 'node_modules') -Recurse -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds $waitSeconds + $attempt++ + continue + } + + Write-Err "npm $command fehlgeschlagen (Exit-Code $exitCode)" + Write-Host " Vollständiges Protokoll: $logFile" -ForegroundColor Yellow + $lastLines = @($output | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Last 20) + foreach ($line in $lastLines) { Write-Host " $line" -ForegroundColor DarkYellow } + Restore-VendooNodeModulesBackup + return $false + } + + Restore-VendooNodeModulesBackup + return $false +} + + +function Get-VendooInstallMode { + if (Test-Path -LiteralPath $InstallModeFile) { + try { + $mode = (Get-Content -LiteralPath $InstallModeFile -Raw).Trim().ToLowerInvariant() + if (@('windows-local', 'docker') -contains $mode) { return $mode } + } catch {} + } + if (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules')) { return 'windows-local' } + return 'windows-local' +} + +function Set-VendooInstallMode([string]$Mode) { + Write-Utf8NoBomFile -Path $InstallModeFile -Content ($Mode.Trim().ToLowerInvariant() + "`n") +} + +function Test-DockerAvailable { + try { + $version = & docker version --format '{{.Server.Version}}' 2>$null + if (-not $version) { return @{ Available = $false; Reason = 'Docker Engine ist nicht erreichbar.' } } + & docker compose version 1>$null 2>$null + if ($LASTEXITCODE -ne 0) { return @{ Available = $false; Reason = 'Das Docker-Compose-Plugin fehlt.' } } + return @{ Available = $true; Version = $version; Reason = '' } + } catch { + return @{ Available = $false; Reason = 'Docker Desktop bzw. Docker Engine wurde nicht gefunden.' } + } +} + +function Get-SecureSetupToken { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Set-VendooEnvValue([string]$Key, [string]$Value) { + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path -LiteralPath $envFile)) { return } + $content = Get-Content -LiteralPath $envFile -Raw + $escaped = [regex]::Escape($Key) + if ($content -match "(?m)^$escaped=") { + $content = [regex]::Replace($content, "(?m)^$escaped=.*$", "$Key=$Value") + } else { + $content = $content.TrimEnd() + "`r`n$Key=$Value`r`n" + } + Write-Utf8NoBomFile -Path $envFile -Content $content +} + +function Get-VendooEnvValue([string]$Key) { + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path -LiteralPath $envFile)) { return '' } + $line = Get-Content -LiteralPath $envFile | Where-Object { $_ -match ('^' + [regex]::Escape($Key) + '=') } | Select-Object -Last 1 + if (-not $line) { return '' } + return ($line -replace ('^' + [regex]::Escape($Key) + '='), '').Trim() +} + +function Initialize-VendooEnvironment([string]$Mode = 'docker') { + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if (-not (Test-Path -LiteralPath $envFile)) { + if (-not (Test-Path -LiteralPath $envExample)) { throw '.env.example fehlt.' } + Copy-Item -LiteralPath $envExample -Destination $envFile + Write-OK '.env aus sicherer Vorlage erstellt' + } + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_SETUP_TOKEN'))) { + $token = Get-SecureSetupToken + Set-VendooEnvValue 'VENDOO_SETUP_TOKEN' $token + Write-Host '' + Write-Host ' ERST-ADMIN-SETUP-CODE:' -ForegroundColor Yellow + Write-Host " $token" -ForegroundColor White + Write-Warn 'Diesen Code jetzt sicher notieren. Er wird bei der ersten Einrichtung benötigt.' + } + if ($Mode -eq 'docker') { + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS'))) { Set-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS' '127.0.0.1' } + Set-VendooEnvValue 'VENDOO_BIND_HOST' '0.0.0.0' + Set-VendooEnvValue 'LOCAL_IMAGE_URL' 'http://host.docker.internal:8188' + } else { + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_BIND_HOST'))) { Set-VendooEnvValue 'VENDOO_BIND_HOST' '127.0.0.1' } + Set-VendooEnvValue 'LOCAL_IMAGE_URL' 'http://127.0.0.1:8188' + } + Set-VendooEnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-VendooEnvValue 'VENDOO_REQUIRE_SETUP_TOKEN' 'true' +} + +function Invoke-VendooDockerCompose([string[]]$Arguments) { + Push-Location $VendooDir + try { + $composeArgs = @('compose', '-f', (Join-Path $VendooDir 'compose.yaml')) + $Arguments + & docker @composeArgs + return $LASTEXITCODE + } finally { Pop-Location } +} + +function Install-VendooDocker { + Write-Header + Write-Host ' Docker-Installation auf diesem Windows-PC ...' -ForegroundColor White + Write-Host '' + $docker = Test-DockerAvailable + if (-not $docker.Available) { + Write-Err $docker.Reason + Write-Host ' Installiere bzw. starte Docker Desktop und führe den Installer erneut aus.' -ForegroundColor Yellow + Write-Host ' Docker Desktop enthält Docker Engine, CLI und Docker Compose.' -ForegroundColor DarkGray + return + } + Write-OK "Docker Engine $($docker.Version) erreichbar" + Initialize-VendooEnvironment -Mode 'docker' + Set-VendooInstallMode 'docker' + Write-Step '1/3' 'Compose-Konfiguration prüfen ...' + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + Write-Step '2/3' 'Vendoo-Container bauen ...' + if ((Invoke-VendooDockerCompose @('build', '--pull')) -ne 0) { Write-Err 'Docker-Build fehlgeschlagen.'; return } + Write-Step '3/3' 'Vendoo starten ...' + if ((Invoke-VendooDockerCompose @('up', '-d', '--remove-orphans')) -ne 0) { Write-Err 'Containerstart fehlgeschlagen.'; return } + Write-OK 'Vendoo läuft im Docker-Modus.' + Write-Host " Readiness: http://127.0.0.1:$Port/readyz" -ForegroundColor Cyan + Start-Process "http://127.0.0.1:$Port/login.html" +} + +function Show-VendooRemoteDeploymentGuide([string]$Target) { + Write-Header + Write-Host " $Target-Installation" -ForegroundColor White + Write-Host '' + Write-Host ' Ein VPS oder NAS wird nicht unsicher vom Windows-PC ferninstalliert.' -ForegroundColor Yellow + Write-Host ' Kopiere den Vendoo-Ordner auf das Zielsystem und starte dort:' -ForegroundColor White + Write-Host '' + Write-Host ' chmod +x setup.sh' -ForegroundColor Cyan + Write-Host ' ./setup.sh' -ForegroundColor Cyan + Write-Host '' + Write-Host ' Der Linux-Assistent bietet Docker, NAS/Portainer und VPS hinter HTTPS-Reverse-Proxy an.' -ForegroundColor DarkGray + Write-Host ' Datenbank, Uploads, .env, Backups und Operations bleiben persistent und werden nicht gelöscht.' -ForegroundColor DarkGray +} + +function Configure-VendooConsole { + Write-Header + Write-Host ' Geführte Konfiguration – keine Datei muss manuell bearbeitet werden.' -ForegroundColor White + Write-Host '' + $mode = Get-VendooInstallMode + $portInput = Read-Host " Port [$Port]" + if (-not [string]::IsNullOrWhiteSpace($portInput)) { + $parsed = 0 + if (-not [int]::TryParse($portInput, [ref]$parsed) -or $parsed -lt 1024 -or $parsed -gt 65535) { Write-Err 'Port muss zwischen 1024 und 65535 liegen.'; return } + $script:Port = $parsed + } + Initialize-VendooEnvironment -Mode ($(if ($mode -eq 'docker') { 'docker' } else { 'windows-local' })) + Set-VendooEnvValue 'PORT' ([string]$Port) + Set-VendooEnvValue 'VENDOO_HOST_PORT' ([string]$Port) + $lan = Read-Host ' Zugriff im privaten LAN erlauben? (ja/nein)' + if ($mode -eq 'docker') { + Set-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS' ($(if ($lan -eq 'ja') { '0.0.0.0' } else { '127.0.0.1' })) + } else { + Set-VendooEnvValue 'VENDOO_BIND_HOST' ($(if ($lan -eq 'ja') { '0.0.0.0' } else { '127.0.0.1' })) + } + if ($lan -eq 'ja') { + $baseUrl = Read-Host ' LAN-Basisadresse für QR-Uploads (optional, z. B. http://192.168.1.20:8124)' + if (-not [string]::IsNullOrWhiteSpace($baseUrl)) { + if ($baseUrl -notmatch '^https?://') { Write-Err 'Adresse muss mit http:// oder https:// beginnen.'; return } + Set-VendooEnvValue 'PUBLIC_BASE_URL' $baseUrl.Trim().TrimEnd('/') + } + } + $flux = Read-Host ' Lokales FLUX aktivieren? (ja/nein)' + Set-VendooEnvValue 'LOCAL_IMAGE_ENABLED' ($(if ($flux -eq 'ja') { 'true' } else { 'false' })) + $token = Get-VendooEnvValue 'VENDOO_SETUP_TOKEN' + try { Set-Clipboard -Value $token -ErrorAction Stop; Write-OK 'Erst-Admin-Code wurde in die Zwischenablage kopiert.' } catch {} + Write-OK 'Konfiguration gespeichert.' + Write-Host ' AI- und Marktplatz-Schlüssel werden später in Vendoo → Einstellungen gepflegt.' -ForegroundColor DarkGray +} + +function Install-Vendoo { + Write-Header + Write-Host ' Wo soll Vendoo betrieben werden?' -ForegroundColor White + Write-Host '' + Write-Host ' 1 Lokal auf diesem Windows-PC (Node.js)' -ForegroundColor Cyan + Write-Host ' 2 Docker Desktop auf diesem Windows-PC' -ForegroundColor Cyan + Write-Host ' 3 NAS / Portainer / Docker-Server' -ForegroundColor Cyan + Write-Host ' 4 VPS / öffentlicher Linux-Server' -ForegroundColor Cyan + Write-Host ' 0 Abbrechen' -ForegroundColor DarkGray + Write-Host '' + $choice = Read-Host ' Installationsziel' + switch ($choice) { + '1' { Set-VendooInstallMode 'windows-local'; Install-VendooLocal } + '2' { Install-VendooDocker } + '3' { Show-VendooRemoteDeploymentGuide 'NAS / Portainer' } + '4' { Show-VendooRemoteDeploymentGuide 'VPS / Linux' } + '0' { return } + default { Write-Warn 'Ungültiges Installationsziel.' } + } +} + +function Start-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + if ((Invoke-VendooDockerCompose @('up', '-d')) -eq 0) { + Write-OK 'Vendoo-Container gestartet.' + Start-Process "http://127.0.0.1:$Port/" + } + return + } + Start-VendooLocal +} + +function Update-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Initialize-VendooEnvironment -Mode 'docker' + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + if ((Invoke-VendooDockerCompose @('build', '--pull')) -ne 0) { Write-Err 'Docker-Update-Build fehlgeschlagen.'; return } + if ((Invoke-VendooDockerCompose @('up', '-d', '--remove-orphans')) -ne 0) { Write-Err 'Docker-Update konnte nicht gestartet werden.'; return } + Write-OK 'Docker-Update abgeschlossen.' + return + } + Update-VendooLocal +} + +function Repair-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + if ((Invoke-VendooDockerCompose @('build', '--no-cache')) -ne 0) { Write-Err 'Docker-Reparatur-Build fehlgeschlagen.'; return } + if ((Invoke-VendooDockerCompose @('up', '-d', '--force-recreate', '--remove-orphans')) -ne 0) { Write-Err 'Docker-Reparaturstart fehlgeschlagen.'; return } + Write-OK 'Docker-Container neu gebaut und gestartet.' + return + } + Repair-VendooLocal +} + +function Install-VendooLocal { + Write-Header + Write-Host ' Installation starten...' -ForegroundColor White + Write-Host '' + + $installState = Get-VendooInstallState + if ($installState.State -eq 'installed') { + Write-Warn 'Vendoo scheint bereits installiert zu sein.' + Write-Host ' Statt versehentlich neu zu installieren, nutze besser Starten, Update oder Reparieren.' -ForegroundColor Yellow + $choice = Read-Host ' Aktion: [S]tarten / [U]pdate / [R]eparieren / [N]eu installieren / [A]bbrechen' + if ([string]::IsNullOrWhiteSpace($choice)) { + $choice = '' + } + $choice = $choice.ToLowerInvariant() + switch ($choice) { + 's' { Start-VendooLocal; return } + 'u' { Update-VendooLocal; return } + 'r' { Repair-VendooLocal; return } + 'a' { return } + 'n' { Write-Host ' Vollständige Neuinstallation wird fortgesetzt...' -ForegroundColor DarkGray } + default { + Write-Warn 'Keine gültige Aktion gewählt. Installation wurde abgebrochen.' + return + } + } + } + + # 1. Node.js pruefen + Write-Step '1/5' 'Node.js pruefen...' + $node = Test-NodeInstalled + if (-not $node.Installed) { + Write-Err 'Node.js ist nicht installiert!' + Write-Host '' + Write-Host ' Node.js wird benoetigt (mindestens v18).' -ForegroundColor Yellow + Write-Host ' Download: https://nodejs.org/' -ForegroundColor Cyan + Write-Host '' + $dl = Read-Host ' Node.js jetzt automatisch installieren? (j/n)' + if ($dl -eq 'j') { + Write-Step '...' 'Lade Node.js Installer herunter...' + $installerUrl = 'https://nodejs.org/dist/v22.16.0/node-v22.16.0-x64.msi' + $installerPath = Join-Path $env:TEMP 'node-installer.msi' + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing + Write-Step '...' 'Installiere Node.js...' + Start-Process msiexec.exe -ArgumentList "/i `"$installerPath`" /qn" -Wait -NoNewWindow + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + Start-Sleep -Seconds 2 + $node = Test-NodeInstalled + if ($node.Installed) { + Write-OK "Node.js $($node.Version) installiert" + } else { + Write-Err 'Installation fehlgeschlagen. Bitte manuell installieren.' + return + } + } catch { + Write-Err "Download fehlgeschlagen: $_" + Write-Host ' Bitte installiere Node.js manuell: https://nodejs.org/' -ForegroundColor Yellow + return + } + } else { + Write-Host ' Installation abgebrochen.' -ForegroundColor Yellow + return + } + } elseif ($node.Major -lt 18) { + Write-Warn "Node.js $($node.Version) ist zu alt. Mindestens v18 erforderlich." + Write-Host ' Update: https://nodejs.org/' -ForegroundColor Cyan + return + } else { + Write-OK "Node.js $($node.Version) gefunden" + } + + # 2. npm pruefen + Write-Step '2/5' 'npm pruefen...' + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV gefunden" + } else { + Write-Err 'npm nicht gefunden. Wird mit Node.js mitgeliefert.' + return + } + + # 3. Dependencies reproduzierbar installieren + Write-Step '3/5' 'Dependencies installieren (npm ci)...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # 4. Sichere Konfiguration erzeugen bzw. übernehmen + Write-Step '4/5' 'Sichere Konfiguration prüfen...' + Initialize-VendooEnvironment -Mode 'windows-local' + Write-OK 'Konfiguration ist vollständig; keine manuelle Dateibearbeitung erforderlich' + + # 5. Ordner pruefen + Write-Step '5/5' 'Verzeichnisse pruefen...' + $dirs = @('uploads', 'db') + foreach ($d in $dirs) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { + New-Item -ItemType Directory -Path $p -Force | Out-Null + } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' ========================================' -ForegroundColor Green + Write-Host ' Installation abgeschlossen!' -ForegroundColor Green + Write-Host ' ========================================' -ForegroundColor Green + Write-Host '' + Write-Host " Starte Vendoo mit: " -NoNewline + Write-Host 'npm start' -ForegroundColor Cyan + Write-Host " Oder im Dev-Modus: " -NoNewline + Write-Host 'npm run dev' -ForegroundColor Cyan + Write-Host " Dann oeffne: " -NoNewline + Write-Host "http://localhost:$Port" -ForegroundColor Cyan + Write-Host '' + + if ($script:AutoStartAfterInstall) { + Start-VendooLocalDetached + return + } + $start = Read-Host ' Vendoo jetzt starten? (j/n)' + if ($start -eq 'j') { + Start-VendooLocal + } +} + +function Start-VendooLocalDetached { + Write-Step '...' 'Starte Vendoo im eigenen Prozess ...' + Start-Process -FilePath 'node.exe' -ArgumentList @('bootstrap.mjs') -WorkingDirectory $VendooDir -WindowStyle Minimized + Start-Sleep -Seconds 2 + Write-OK 'Vendoo wurde gestartet.' +} + +function Start-VendooLocal { + Write-Host '' + Write-Step '...' 'Starte Vendoo-Server...' + Push-Location $VendooDir + Start-Process "http://localhost:$Port/?build=1.36.0" + & node bootstrap.mjs + Pop-Location +} + +function Update-VendooLocal { + Write-Header + Write-Host ' Update durchfuehren...' -ForegroundColor White + Write-Host '' + + # Dependencies reproduzierbar aktualisieren + Write-Step '1/2' 'Dependencies aktualisieren...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # Neue .env Eintraege pruefen + Write-Step '2/2' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if ((Test-Path $envFile) -and (Test-Path $envExample)) { + $current = Get-Content $envFile -Raw + $example = Get-Content $envExample + $added = 0 + foreach ($line in $example) { + if ($line -match '^([A-Z_]+)=' -and $current -notmatch "(?m)^$($Matches[1])=") { + Add-Content $envFile "`n$line" + $added++ + } + } + if ($added -gt 0) { + Write-OK "$added neue Konfigurationseintraege hinzugefuegt" + } else { + Write-OK '.env ist aktuell' + } + } + + Write-Host '' + Write-Host ' Update abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Repair-VendooLocal { + Write-Header + Write-Host ' Reparatur durchfuehren...' -ForegroundColor White + Write-Host '' + + # 1. node_modules robust loeschen und reproduzierbar neu installieren + Write-Step '1/3' 'node_modules neu installieren...' + if (-not (Invoke-VendooNpmInstall -ForceClean)) { return } + + # 2. .env pruefen + Write-Step '2/3' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path $envFile)) { + $envExample = Join-Path $VendooDir '.env.example' + if (Test-Path $envExample) { Copy-Item $envExample $envFile } + Write-Warn '.env fehlte und wurde neu erstellt' + } else { + Write-OK '.env vorhanden' + } + + # 3. Ordner pruefen + Write-Step '3/3' 'Verzeichnisse pruefen...' + foreach ($d in @('uploads', 'db')) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' Reparatur abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Uninstall-VendooLocal { + Write-Header + Write-Host ' Vendoo deinstallieren' -ForegroundColor Red + Write-Host '' + Write-Warn 'Dies entfernt node_modules und optionale Daten.' + Write-Host '' + + $confirm = Read-Host ' Wirklich deinstallieren? (ja/nein)' + if ($confirm -ne 'ja') { + Write-Host ' Abgebrochen.' -ForegroundColor Yellow + return + } + + $keepData = Read-Host ' Datenbank und Uploads behalten? (j/n)' + + Write-Step '1/2' 'node_modules entfernen...' + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { + Remove-Item $nm -Recurse -Force -Confirm:$false + Write-OK 'node_modules entfernt' + } else { + Write-OK 'node_modules nicht vorhanden' + } + + if ($keepData -ne 'j') { + Write-Step '2/2' 'Daten entfernen...' + $dbDir = Join-Path $VendooDir 'db' + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $dbDir) { + Remove-Item (Join-Path $dbDir '*.db'), (Join-Path $dbDir '*.db-shm'), (Join-Path $dbDir '*.db-wal') -Force -ErrorAction SilentlyContinue + Write-OK 'Datenbank entfernt' + } + if (Test-Path $uploadsDir) { + Get-ChildItem $uploadsDir -File | Remove-Item -Force -Confirm:$false + Write-OK 'Uploads entfernt' + } + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { + Remove-Item $envFile -Force -Confirm:$false + Write-OK '.env entfernt' + } + } else { + Write-OK 'Datenbank und Uploads beibehalten' + } + + Write-Host '' + Write-Host ' Vendoo wurde deinstalliert.' -ForegroundColor Green + Write-Host ' Die Projektdateien bleiben erhalten.' -ForegroundColor DarkGray + Write-Host ' Zum erneuten Installieren: setup.bat ausfuehren.' -ForegroundColor DarkGray + Write-Host '' +} + +function Install-LocalFlux { + Write-Header + Write-Host ' Lokales FLUX / ComfyUI installieren' -ForegroundColor White + Write-Host '' + $script = Get-LocalFluxScriptPath + if (-not (Test-Path $script)) { + Write-Err 'Installationsskript fuer lokales FLUX wurde nicht gefunden.' + return + } + + $stateFile = Join-Path $VendooDir 'local-ai\install-state.json' + $existingInstall = Test-Path (Join-Path $VendooDir 'local-ai\comfyui\ComfyUI_windows_portable\ComfyUI\main.py') + if ($existingInstall) { + Write-OK 'ComfyUI ist bereits installiert.' + $existingChoice = Read-Host ' [S]tatus / [U]pdate / [M]odell nachinstallieren / [N]eu installieren / [A]bbrechen' + if ([string]::IsNullOrWhiteSpace($existingChoice)) { return } + $existingChoice = $existingChoice.ToLowerInvariant() + switch ($existingChoice) { + 's' { Show-LocalFluxStatus; return } + 'u' { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -UpdateOnly -StartNow + return + } + 'm' { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -InstallModel -StartNow + return + } + 'n' { } + default { return } + } + } + + Write-Host ' Hinweis: Das FLUX-Modell ist sehr gross und kann lange herunterladen.' -ForegroundColor Yellow + $modelChoice = Read-Host ' FLUX.1-schnell FP8 direkt mitinstallieren? (j/n)' + $autoChoice = Read-Host ' Lokalen Bildserver bei Windows-Anmeldung automatisch starten? (j/n)' + $startChoice = Read-Host ' Nach Installation sofort starten? (j/n)' + + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script) + if ($modelChoice -eq 'j') { $arguments += '-InstallModel' } + if ($autoChoice -eq 'j') { $arguments += '-EnableAutostart' } + if ($startChoice -eq 'j') { $arguments += '-StartNow' } + if ($existingInstall) { $arguments += '-Force' } + + Write-Step '1/1' 'Starte automatischen lokalen FLUX-Installer...' + & powershell.exe @arguments + if ($LASTEXITCODE -eq 0) { + Write-OK 'Lokales FLUX / ComfyUI wurde eingerichtet.' + } else { + Write-Err 'Lokale FLUX-Installation ist fehlgeschlagen.' + if (Test-Path -LiteralPath $stateFile) { + try { + $failedState = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + Write-Host " Phase: $($failedState.phase)" -ForegroundColor Yellow + Write-Host " Fortschritt: $($failedState.progress)%" -ForegroundColor Yellow + Write-Host " Fehler: $($failedState.message)" -ForegroundColor Red + } catch {} + Write-Host " Statusdatei: $stateFile" -ForegroundColor Yellow + } + Write-Host " Log: $(Join-Path $VendooDir 'logs\local-flux-install.log')" -ForegroundColor Yellow + Write-Host ' Option 7 kann nach dem Fix erneut ausgeführt werden; vorhandene vollständige Downloads werden weiterverwendet.' -ForegroundColor DarkGray + } + Write-Host '' +} + + +function Get-LocalFluxStartupRecovery { + $stateFile = Join-Path $VendooDir 'local-ai\install-state.json' + $extractRoot = Join-Path $VendooDir 'local-ai\extract-temp' + $stdoutFile = Join-Path $VendooDir 'logs\local-flux-7zip-output.log' + $stderrFile = Join-Path $VendooDir 'logs\local-flux-7zip-error.log' + $portableRoot = Join-Path $VendooDir 'local-ai\comfyui\ComfyUI_windows_portable' + $finalMain = Join-Path $portableRoot 'ComfyUI\main.py' + $embeddedPython = Join-Path $portableRoot 'python_embeded\python.exe' + $modelPath = Join-Path $portableRoot 'ComfyUI\models\checkpoints\flux1-schnell-fp8.safetensors' + $partialModel = "$modelPath.part" + + if (-not (Test-Path -LiteralPath $stateFile)) { return $null } + + try { + $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + } catch { + return $null + } + + try { + $active = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine -match [regex]::Escape($VendooDir) -and + ($_.Name -match '^(7z|7zG|powershell)\.exe$') -and + $_.CommandLine -match 'install-local-flux|extract-temp|ComfyUI_windows_portable|flux1-schnell' + }) + if ($active.Count -gt 0) { return $null } + } catch {} + + # Bekannter Slice-22-Fehlalarm nach erfolgreicher 7-Zip-Entpackung. + if ((Test-Path -LiteralPath $extractRoot) -and -not (Test-Path -LiteralPath $finalMain)) { + $knownFalseFailure = ( + $state.status -eq 'failed' -and + [bool]$state.can_resume -and + [string]$state.message -match '7-Zip Exit-Code\s*\)' -and + [string]$state.message -match 'Everything is Ok' + ) + + if ($knownFalseFailure) { + $stderrText = '' + if (Test-Path -LiteralPath $stderrFile) { + try { $stderrText = (Get-Content -LiteralPath $stderrFile -Raw -ErrorAction Stop).Trim() } catch {} + } + + $stdoutText = '' + if (Test-Path -LiteralPath $stdoutFile) { + try { $stdoutText = Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction Stop } catch {} + } + + if ([string]::IsNullOrWhiteSpace($stderrText) -and $stdoutText -match '(?im)^Everything is Ok\s*$') { + $portable = Get-ChildItem -LiteralPath $extractRoot -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match '^ComfyUI_windows_portable' -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'python_embeded\python.exe')) + } | + Select-Object -First 1 + + if ($portable) { + return @{ + Mode = 'extract' + State = $state + PortablePath = $portable.FullName + ExtractRoot = $extractRoot + } + } + } + } + } + + # Unterbrochener oder scheinbar hängender Modell-Download. + $comfyReady = (Test-Path -LiteralPath $finalMain) -and (Test-Path -LiteralPath $embeddedPython) + $modelReady = $false + if (Test-Path -LiteralPath $modelPath) { + try { $modelReady = (Get-Item -LiteralPath $modelPath).Length -ge 1GB } catch {} + } + + $partialBytes = 0L + if (Test-Path -LiteralPath $partialModel) { + try { $partialBytes = [long](Get-Item -LiteralPath $partialModel).Length } catch {} + } + + $downloadState = ([string]$state.phase -eq 'download-model') -and ([string]$state.status -in @('running','failed')) + if ($comfyReady -and -not $modelReady -and ($downloadState -or $partialBytes -gt 0)) { + return @{ + Mode = 'model-download' + State = $state + PartialPath = $partialModel + PartialBytes = $partialBytes + } + } + + return $null +} + +function Invoke-LocalFluxStartupRecovery { + $recovery = Get-LocalFluxStartupRecovery + if (-not $recovery) { return } + + Write-Header + Write-Host ' Automatische FLUX-Wiederaufnahme' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + Write-Host '' + + if ($recovery.Mode -eq 'extract') { + Write-OK 'Das ComfyUI-Archiv wurde bereits vollständig und fehlerfrei entpackt.' + Write-Warn 'Der frühere Installer hat den erfolgreichen 7-Zip-Lauf falsch bewertet.' + Write-Host ' Der vorhandene Entpackstand wird übernommen; nichts wird neu entpackt.' -ForegroundColor Green + } elseif ($recovery.Mode -eq 'model-download') { + $partialMb = [math]::Round(([long]$recovery.PartialBytes) / 1MB, 1) + Write-Warn 'Ein unterbrochener oder nicht sichtbar fortschreitender FLUX-Download wurde erkannt.' + if ($partialMb -gt 0) { + Write-OK "Bereits geladene Daten bleiben erhalten: $partialMb MB." + } + Write-Host ' Der Download wird mit sichtbarem Fortschritt automatisch fortgesetzt.' -ForegroundColor Green + Write-Host ' Anzeige: Prozent · geladene MB · Gesamtgröße · Geschwindigkeit · Restzeit' -ForegroundColor DarkGray + } + + Write-Host '' + Write-Step 'AUTO' 'Fortsetzung startet automatisch...' + Start-Sleep -Seconds 2 + + $script = Get-LocalFluxScriptPath + if (-not (Test-Path -LiteralPath $script)) { + Write-Err 'FLUX-Installationsskript fehlt. Automatische Fortsetzung ist nicht möglich.' + return + } + + $logDir = Join-Path $VendooDir 'logs' + if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + } + $selfHealLog = Join-Path $logDir 'setup-self-heal.log' + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Automatische FLUX-Wiederaufnahme gestartet. Modus: $($recovery.Mode)." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + + & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File $script -InstallModel -StartNow + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [OK] Automatische FLUX-Wiederaufnahme erfolgreich abgeschlossen." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + Write-OK 'ComfyUI und FLUX wurden automatisch fertig eingerichtet.' + Write-Host '' + Show-LocalFluxStatus + } else { + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] Automatische FLUX-Wiederaufnahme fehlgeschlagen. Exit-Code $exitCode." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + Write-Err "Automatische Fortsetzung ist fehlgeschlagen (Exit-Code $exitCode)." + Write-Host " Diagnose: $selfHealLog" -ForegroundColor Yellow + Write-Host " Installer-Log: $(Join-Path $VendooDir 'logs\local-flux-install.log')" -ForegroundColor Yellow + } + + Write-Host '' +} + +function Start-LocalFlux { + $script = Join-Path $VendooDir 'local-ai\start-local-flux.ps1' + if (-not (Test-Path $script)) { + Write-Warn 'Startskript fehlt. Bitte lokales FLUX zuerst installieren.' + return + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script + Start-Sleep -Seconds 3 + Show-LocalFluxStatus +} + +function Stop-LocalFlux { + $script = Join-Path $VendooDir 'local-ai\stop-local-flux.ps1' + if (-not (Test-Path $script)) { + Write-Warn 'Stoppskript fehlt.' + return + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script + Write-OK 'Lokaler FLUX / ComfyUI-Prozess wurde beendet.' +} + +function Show-LocalFluxStatus { + Write-Header + Write-Host ' Lokales FLUX / ComfyUI Status' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + + $localRoot = Join-Path $VendooDir 'local-ai' + $stateFile = Join-Path $localRoot 'install-state.json' + $logFile = Join-Path $VendooDir 'logs\local-flux-install.log' + $portableRoot = Join-Path $localRoot 'comfyui\ComfyUI_windows_portable' + $pythonPath = Join-Path $portableRoot 'python_embeded\python.exe' + $comfyMain = Join-Path $portableRoot 'ComfyUI\main.py' + $modelPath = Join-Path $portableRoot 'ComfyUI\models\checkpoints\flux1-schnell-fp8.safetensors' + $workflowPath = Join-Path $localRoot 'workflows\vendoo_flux_schnell_api.json' + $startScript = Join-Path $localRoot 'start-local-flux.ps1' + $stopScript = Join-Path $localRoot 'stop-local-flux.ps1' + + $state = $null + if (Test-Path -LiteralPath $stateFile) { + try { $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json } catch {} + } + + if ($state) { + $statusColor = 'Yellow' + if ($state.status -eq 'completed') { $statusColor = 'Green' } + elseif ($state.status -eq 'failed') { $statusColor = 'Red' } + $heartbeatAge = $null + try { + $heartbeatTime = [DateTime]::Parse([string]$(if ($state.heartbeat_at) { $state.heartbeat_at } else { $state.updated_at })) + $heartbeatAge = [math]::Round(((Get-Date) - $heartbeatTime).TotalSeconds) + } catch {} + $staleThreshold = if ($state.phase -eq 'extract-comfyui') { 20 } else { 120 } + if ($state.status -eq 'running' -and $heartbeatAge -ne $null -and $heartbeatAge -gt $staleThreshold) { + $statusColor = 'Red' + Write-Host ' Status: HÄNGT / keine Aktualisierung' -ForegroundColor $statusColor + } else { + Write-Host " Status: $($state.status)" -ForegroundColor $statusColor + } + Write-Host " Phase: $($state.phase)" -ForegroundColor DarkGray + Write-Host " Fortschritt: $($state.progress)%" -ForegroundColor DarkGray + Write-Host " Meldung: $($state.message)" -ForegroundColor DarkGray + if ($state.elapsed_seconds -ne $null) { Write-Host " Laufzeit: $($state.elapsed_seconds) Sekunden" -ForegroundColor DarkGray } + if ($state.extract_files -ne $null) { Write-Host " Entpackt: $($state.extract_files) Dateien / $($state.extract_mb) MB" -ForegroundColor DarkGray } + if ($heartbeatAge -ne $null) { Write-Host " Heartbeat: vor $heartbeatAge Sekunden" -ForegroundColor DarkGray } + Write-Host " Aktualisiert:$($state.updated_at)" -ForegroundColor DarkGray + } else { + Write-Warn 'Noch keine Installationsstatusdatei vorhanden.' + } + + Write-Host '' + if (Test-Path -LiteralPath "$env:ProgramFiles\7-Zip\7z.exe") { Write-OK '7-Zip Vorhanden' } + else { Write-Warn '7-Zip Nicht im Standardpfad gefunden' } + if (Test-Path -LiteralPath $pythonPath) { Write-OK 'Python Vorhanden' } + else { Write-Warn 'Python Fehlt' } + if (Test-Path -LiteralPath $comfyMain) { Write-OK 'ComfyUI Installiert' } + else { Write-Warn 'ComfyUI Noch nicht vollständig installiert' } + if (Test-Path -LiteralPath $workflowPath) { Write-OK 'Workflow Vorhanden' } + else { Write-Warn 'Workflow Fehlt' } + if (Test-Path -LiteralPath $modelPath) { + $modelSize = [math]::Round((Get-Item -LiteralPath $modelPath).Length / 1GB, 2) + Write-OK "FLUX-Modell Vorhanden ($modelSize GB)" + } else { Write-Warn 'FLUX-Modell Fehlt' } + if (Test-Path -LiteralPath $startScript) { Write-OK 'Startskript Vorhanden' } + else { Write-Warn 'Startskript Fehlt' } + if (Test-Path -LiteralPath $stopScript) { Write-OK 'Stoppskript Vorhanden' } + else { Write-Warn 'Stoppskript Fehlt' } + + $envFile = Join-Path $VendooDir '.env' + $url = 'http://127.0.0.1:8188' + if (Test-Path -LiteralPath $envFile) { + $envContent = Get-Content -LiteralPath $envFile -Raw + if ($envContent -match '(?m)^LOCAL_IMAGE_URL=(.+)$') { $url = $Matches[1].Trim() } + } + $serverReachable = $false + try { + $resp = Invoke-WebRequest -UseBasicParsing -Uri ($url.TrimEnd('/') + '/system_stats') -TimeoutSec 5 + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) { + $serverReachable = $true + Write-OK 'Server Erreichbar' + } else { Write-Warn "Server HTTP $($resp.StatusCode)" } + } catch { + Write-Warn 'Server Nicht erreichbar' + } + + if (-not $serverReachable) { + $runtimeStateFile = Join-Path $VendooDir 'logs\local-flux-runtime-state.json' + if (Test-Path -LiteralPath $runtimeStateFile) { + try { + $runtimeState = Get-Content -LiteralPath $runtimeStateFile -Raw | ConvertFrom-Json + Write-Host " Runtime: $($runtimeState.status)" -ForegroundColor $(if ($runtimeState.status -eq 'failed') { 'Red' } elseif ($runtimeState.status -eq 'starting') { 'Yellow' } else { 'DarkGray' }) + Write-Host " Startinfo: $($runtimeState.message)" -ForegroundColor DarkGray + if ($runtimeState.error) { Write-Host " Fehler: $($runtimeState.error)" -ForegroundColor Red } + } catch {} + } + foreach ($runtimeLog in @( + (Join-Path $VendooDir 'logs\local-flux-runtime-error.log'), + (Join-Path $VendooDir 'logs\local-flux-runtime.log') + )) { + if (Test-Path -LiteralPath $runtimeLog) { + $runtimeLines = @(Get-Content -LiteralPath $runtimeLog -Tail 8 -ErrorAction SilentlyContinue) + if ($runtimeLines.Count -gt 0) { + Write-Host " Runtime-Log: $runtimeLog" -ForegroundColor White + $runtimeLines | ForEach-Object { + $clean = [regex]::Replace([string]$_, ([string][char]27) + '\[[0-?]*[ -/]*[@-~]', '') + Write-Host " $clean" -ForegroundColor DarkGray + } + } + } + } + } + + if (Test-Path -LiteralPath $logFile) { + Write-Host '' + Write-Host ' Letzte Installationsmeldungen:' -ForegroundColor White + Get-Content -LiteralPath $logFile -Tail 12 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } + $sevenZipError = Join-Path $VendooDir 'logs\local-flux-7zip-error.log' + if (Test-Path -LiteralPath $sevenZipError) { + $sevenZipLines = @(Get-Content -LiteralPath $sevenZipError -Tail 8 -ErrorAction SilentlyContinue) + if ($sevenZipLines.Count -gt 0) { + Write-Host '' + Write-Host ' Letzte 7-Zip-Fehlerausgabe:' -ForegroundColor White + $sevenZipLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } + } + Write-Host '' +} + + +function Show-StatusLocal { + Write-Header + Write-Host ' Systemstatus' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + + $node = Test-NodeInstalled + if ($node.Installed) { Write-OK "Node.js $($node.Version)" } + else { Write-Err 'Node.js Nicht installiert' } + + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV" + } else { Write-Err 'npm Nicht gefunden' } + + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { Write-OK 'Dependencies Installiert' } + else { Write-Warn 'Dependencies Nicht installiert' } + + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { Write-OK '.env Vorhanden' } + else { Write-Warn '.env Fehlt' } + + $dbFile = Join-Path $VendooDir 'db' 'vendoo.db' + if (Test-Path $dbFile) { + $size = (Get-Item $dbFile).Length + $sizeStr = if ($size -gt 1MB) { "{0:N1} MB" -f ($size / 1MB) } else { "{0:N0} KB" -f ($size / 1KB) } + Write-OK "Datenbank $sizeStr" + } else { Write-Warn 'Datenbank Noch nicht erstellt (wird beim ersten Start angelegt)' } + + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $uploadsDir) { + $fileCount = (Get-ChildItem $uploadsDir -File -ErrorAction SilentlyContinue).Count + Write-OK "Uploads $fileCount Dateien" + } + + Write-Host '' + Write-Host " Port: $Port" -ForegroundColor DarkGray + Write-Host " Verzeichnis: $VendooDir" -ForegroundColor DarkGray + Write-Host '' +} + + +function Show-Status { + if ((Get-VendooInstallMode) -eq 'docker') { + Write-Header + Write-Host ' Docker-Systemstatus' -ForegroundColor White + Write-Host '' + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Write-OK "Docker Engine $($docker.Version)" + Invoke-VendooDockerCompose @('ps') | Out-Null + try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/readyz" -UseBasicParsing -TimeoutSec 5 + if ($response.StatusCode -eq 200) { Write-OK 'Readiness Bereit' } + else { Write-Warn "Readiness HTTP $($response.StatusCode)" } + } catch { Write-Warn 'Readiness Nicht erreichbar' } + Write-Host '' + Write-Host " Installationsmodus: Docker Desktop" -ForegroundColor DarkGray + return + } + Show-StatusLocal +} + +function Uninstall-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + Write-Header + Write-Host ' Vendoo-Dockerbereitstellung entfernen' -ForegroundColor Red + Write-Host '' + Write-Warn 'Container werden gestoppt. Persistente Volumes bleiben standardmäßig erhalten.' + $confirm = Read-Host ' Container wirklich entfernen? (ja/nein)' + if ($confirm -ne 'ja') { Write-Host ' Abgebrochen.' -ForegroundColor Yellow; return } + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Invoke-VendooDockerCompose @('down') | Out-Null + Write-OK 'Container und Netzwerk entfernt. Datenvolumes wurden beibehalten.' + $deleteVolumes = Read-Host ' Persistente Docker-Volumes ebenfalls endgültig löschen? Tippe dafür exakt DATEN LOESCHEN' + if ($deleteVolumes -eq 'DATEN LOESCHEN') { + Invoke-VendooDockerCompose @('down', '-v') | Out-Null + Write-Warn 'Persistente Docker-Volumes wurden gelöscht.' + } + return + } + Uninstall-VendooLocal +} + + +# ── Vendoo Operations Center 1.30.0 ── +function Get-VendooOperationsDir { return (Join-Path $VendooDir 'operations') } +function Get-VendooPendingOperationFile { return (Join-Path (Get-VendooOperationsDir) 'pending-operation.json') } +function Get-VendooLastOperationFile { return (Join-Path (Get-VendooOperationsDir) 'last-operation.json') } + +function Test-VendooServerStopped { + try { + $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if ($listeners.Count -gt 0) { + Write-Err "Vendoo lauscht noch auf Port $Port. Bitte das Serverfenster zuerst schliessen." + return $false + } + } catch { + try { + $client = New-Object System.Net.Sockets.TcpClient + $async = $client.BeginConnect('127.0.0.1', $Port, $null, $null) + if ($async.AsyncWaitHandle.WaitOne(350, $false) -and $client.Connected) { + $client.Close() + Write-Err "Vendoo ist noch aktiv. Bitte das Serverfenster zuerst schliessen." + return $false + } + $client.Close() + } catch {} + } + return $true +} + +function Copy-VendooCodeSnapshot([string]$Target) { + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $rootFiles = @('.env.example','.gitignore','.npmrc','CHANGELOG.md','CLAUDE.md','FEATURES.md','README.md','SECURITY.md','THIRD_PARTY_NOTICES.md','package.json','package-lock.json','server.mjs','setup.bat','setup.ps1','update-manifest.json') + foreach ($name in $rootFiles) { + $source = Join-Path $VendooDir $name + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $Target $name) -Force } + } + foreach ($name in @('public','lib','platforms','scripts','tools','docs','extensions','extension','MockupDesign')) { + $source = Join-Path $VendooDir $name + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $Target $name) -Recurse -Force } + } + $localAiTarget = Join-Path $Target 'local-ai' + New-Item -ItemType Directory -Path $localAiTarget -Force | Out-Null + foreach ($name in @('start-local-flux.ps1','stop-local-flux.ps1')) { + $source = Join-Path $VendooDir ('local-ai\' + $name) + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $localAiTarget $name) -Force } + } + $workflowSource = Join-Path $VendooDir 'local-ai\workflows' + if (Test-Path -LiteralPath $workflowSource) { Copy-Item -LiteralPath $workflowSource -Destination (Join-Path $localAiTarget 'workflows') -Recurse -Force } + $schemaSource = Join-Path $VendooDir 'db\schema.sql' + if (Test-Path -LiteralPath $schemaSource) { + New-Item -ItemType Directory -Path (Join-Path $Target 'db') -Force | Out-Null + Copy-Item -LiteralPath $schemaSource -Destination (Join-Path $Target 'db\schema.sql') -Force + } +} + +function Copy-VendooRuntimeSnapshot([string]$Target) { + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $dbFile = Join-Path $VendooDir 'db\vendoo.db' + if (Test-Path -LiteralPath $dbFile) { + New-Item -ItemType Directory -Path (Join-Path $Target 'db') -Force | Out-Null + Copy-Item -LiteralPath $dbFile -Destination (Join-Path $Target 'db\vendoo.db') -Force + } + $uploads = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploads) { Copy-Item -LiteralPath $uploads -Destination (Join-Path $Target 'uploads') -Recurse -Force } + $envFile = Join-Path $VendooDir '.env' + if (Test-Path -LiteralPath $envFile) { Copy-Item -LiteralPath $envFile -Destination (Join-Path $Target '.env') -Force } +} + +function Copy-VendooTree([string]$Source, [string]$Target) { + if (-not (Test-Path -LiteralPath $Source)) { throw "Quellverzeichnis fehlt: $Source" } + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $null = & robocopy.exe $Source $Target /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP + $code = $LASTEXITCODE + if ($code -gt 7) { throw "Robocopy fehlgeschlagen (Exit-Code $code): $Source -> $Target" } +} + +function Restore-VendooCodeSnapshot([string]$Source) { + if (-not (Test-Path -LiteralPath $Source)) { throw "Rollback-Snapshot fehlt: $Source" } + Copy-VendooTree -Source $Source -Target $VendooDir +} + +function Restore-VendooRuntimeSnapshot([string]$Source) { + if (-not (Test-Path -LiteralPath $Source)) { return } + $dbSource = Join-Path $Source 'db\vendoo.db' + if (Test-Path -LiteralPath $dbSource) { + New-Item -ItemType Directory -Path (Join-Path $VendooDir 'db') -Force | Out-Null + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-wal') -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-shm') -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $dbSource -Destination (Join-Path $VendooDir 'db\vendoo.db') -Force + } + $uploadsSource = Join-Path $Source 'uploads' + if (Test-Path -LiteralPath $uploadsSource) { + $uploadsTarget = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploadsTarget) { Remove-Item -LiteralPath $uploadsTarget -Recurse -Force } + Copy-Item -LiteralPath $uploadsSource -Destination $uploadsTarget -Recurse -Force + } + $envSource = Join-Path $Source '.env' + if (Test-Path -LiteralPath $envSource) { Copy-Item -LiteralPath $envSource -Destination (Join-Path $VendooDir '.env') -Force } +} + +function Find-VendooUpdateRoot([string]$ExtractRoot) { + $packages = @(Get-ChildItem -LiteralPath $ExtractRoot -Filter 'package.json' -File -Recurse -ErrorAction SilentlyContinue) + foreach ($package in $packages) { + try { + $json = Get-Content -LiteralPath $package.FullName -Raw | ConvertFrom-Json + if ($json.name -eq 'vendoo' -and (Test-Path -LiteralPath (Join-Path $package.Directory.FullName 'server.mjs'))) { return $package.Directory.FullName } + } catch {} + } + return $null +} + +function Apply-VendooPendingOperation { + Write-Header + Write-Host ' Vorbereitete Systemoperation anwenden' -ForegroundColor White + Write-Host '' + $pendingFile = Get-VendooPendingOperationFile + if (-not (Test-Path -LiteralPath $pendingFile)) { Write-Warn 'Keine vorbereitete Operation vorhanden.'; return } + if (-not (Test-VendooServerStopped)) { return } + + try { $operation = Get-Content -LiteralPath $pendingFile -Raw | ConvertFrom-Json } + catch { Write-Err 'pending-operation.json ist ungueltig.'; return } + if (-not $operation.verified) { Write-Err 'Die Operation wurde nicht als verifiziert markiert.'; return } + if (-not (Test-Path -LiteralPath $operation.archive_path)) { Write-Err "Archiv fehlt: $($operation.archive_path)"; return } + + Write-Host " Typ: $($operation.type)" -ForegroundColor DarkGray + if ($operation.version_to) { Write-Host " Version: $($operation.version_from) -> $($operation.version_to)" -ForegroundColor DarkGray } + Write-Host " Archiv: $($operation.archive_path)" -ForegroundColor DarkGray + Write-Host '' + $confirm = Read-Host ' Jetzt anwenden? (ja/nein)' + if ($confirm -ne 'ja') { Write-Warn 'Operation abgebrochen.'; return } + + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $rollbackRoot = Join-Path (Get-VendooOperationsDir) ("rollback\" + $stamp) + $codeRollback = Join-Path $rollbackRoot 'code' + $runtimeRollback = Join-Path $rollbackRoot 'runtime' + $extractRoot = Join-Path (Get-VendooOperationsDir) ("apply-" + $stamp) + New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null + + try { + Write-Step '1/5' 'Rollback-Snapshot erstellen...' + Copy-VendooCodeSnapshot -Target $codeRollback + Copy-VendooRuntimeSnapshot -Target $runtimeRollback + Write-OK 'Rollback-Snapshot erstellt' + + Write-Step '2/5' 'Archiv entpacken...' + Expand-Archive -LiteralPath $operation.archive_path -DestinationPath $extractRoot -Force + Write-OK 'Archiv entpackt' + + if ($operation.type -eq 'update') { + Write-Step '3/5' 'Vendoo-Programmdateien aktualisieren...' + $sourceRoot = Find-VendooUpdateRoot -ExtractRoot $extractRoot + if (-not $sourceRoot) { throw 'Vendoo-Quellordner wurde im Update nicht gefunden.' } + Copy-VendooTree -Source $sourceRoot -Target $VendooDir + Write-OK 'Programmdateien aktualisiert' + Write-Step '4/5' 'Dependencies und Konfiguration aktualisieren...' + Update-Vendoo + & node --check (Join-Path $VendooDir 'server.mjs') + if ($LASTEXITCODE -ne 0) { throw 'server.mjs ist nach dem Update syntaktisch ungueltig.' } + if (-not (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules'))) { throw 'node_modules fehlt nach dem Update.' } + } + elseif ($operation.type -eq 'restore') { + Write-Step '3/5' 'Datenbank wiederherstellen...' + $dbSource = Join-Path $extractRoot 'db\vendoo.db' + if (-not (Test-Path -LiteralPath $dbSource)) { throw 'Datenbank fehlt im Backup.' } + New-Item -ItemType Directory -Path (Join-Path $VendooDir 'db') -Force | Out-Null + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-wal') -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-shm') -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $dbSource -Destination (Join-Path $VendooDir 'db\vendoo.db') -Force + Write-OK 'Datenbank wiederhergestellt' + Write-Step '4/5' 'Uploads und Konfiguration wiederherstellen...' + $backupManifest = $null + $manifestPath = Join-Path $extractRoot 'backup-manifest.json' + if (Test-Path -LiteralPath $manifestPath) { + try { $backupManifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch {} + } + $uploadsSource = Join-Path $extractRoot 'uploads' + if ($backupManifest -and $backupManifest.include_uploads) { + $uploadsTarget = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploadsTarget) { Remove-Item -LiteralPath $uploadsTarget -Recurse -Force } + if (Test-Path -LiteralPath $uploadsSource) { Copy-Item -LiteralPath $uploadsSource -Destination $uploadsTarget -Recurse -Force } + else { New-Item -ItemType Directory -Path $uploadsTarget -Force | Out-Null } + } + $envSource = Join-Path $extractRoot 'config\.env' + if (Test-Path -LiteralPath $envSource) { Copy-Item -LiteralPath $envSource -Destination (Join-Path $VendooDir '.env') -Force } + Write-OK 'Enthaltene Laufzeitdaten wiederhergestellt' + } + else { throw "Unbekannter Operationstyp: $($operation.type)" } + + Write-Step '5/5' 'Operation abschliessen...' + $last = [ordered]@{ + id = [string]$operation.id + type = [string]$operation.type + status = 'applied' + version_from = [string]$operation.version_from + version_to = [string]$operation.version_to + rollback_path = $rollbackRoot + applied_at = (Get-Date).ToString('o') + } + $last | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Get-VendooLastOperationFile) -Encoding UTF8 + Remove-Item -LiteralPath $pendingFile -Force + Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue + Write-OK 'Operation erfolgreich abgeschlossen' + Write-Host '' + Write-Host ' Starte Vendoo jetzt ueber Menuepunkt 2 und pruefe Admin > System.' -ForegroundColor Green + } catch { + Write-Err ("Operation fehlgeschlagen: " + $_.Exception.Message) + Write-Warn 'Der vorherige Stand wird aus dem gerade erstellten Snapshot wiederhergestellt.' + try { + Restore-VendooCodeSnapshot -Source $codeRollback + Restore-VendooRuntimeSnapshot -Source $runtimeRollback + Write-OK 'Vorheriger Stand wiederhergestellt' + } catch { Write-Err ("Automatischer Rollback fehlgeschlagen: " + $_.Exception.Message) } + } finally { + Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-VendooRollback { + Write-Header + Write-Host ' Letzte Systemoperation zuruecksetzen' -ForegroundColor White + Write-Host '' + if (-not (Test-VendooServerStopped)) { return } + $lastFile = Get-VendooLastOperationFile + if (-not (Test-Path -LiteralPath $lastFile)) { Write-Warn 'Kein Rollback-Snapshot vorhanden.'; return } + try { $last = Get-Content -LiteralPath $lastFile -Raw | ConvertFrom-Json } + catch { Write-Err 'last-operation.json ist ungueltig.'; return } + if (-not $last.rollback_path -or -not (Test-Path -LiteralPath $last.rollback_path)) { Write-Err 'Rollback-Verzeichnis wurde nicht gefunden.'; return } + Write-Host " Letzte Operation: $($last.type)" -ForegroundColor DarkGray + Write-Host " Zeitpunkt: $($last.applied_at)" -ForegroundColor DarkGray + Write-Host '' + if ((Read-Host ' Vorherigen Stand wiederherstellen? (ja/nein)') -ne 'ja') { Write-Warn 'Rollback abgebrochen.'; return } + try { + Restore-VendooCodeSnapshot -Source (Join-Path $last.rollback_path 'code') + Restore-VendooRuntimeSnapshot -Source (Join-Path $last.rollback_path 'runtime') + $last.status = 'rolled_back' + $last | Add-Member -NotePropertyName rolled_back_at -NotePropertyValue (Get-Date).ToString('o') -Force + $last | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $lastFile -Encoding UTF8 + Write-OK 'Vorheriger Vendoo-Stand wurde wiederhergestellt.' + Write-Warn 'Fuehre bei Bedarf danach Menuepunkt 3 – Update aus.' + } catch { Write-Err ("Rollback fehlgeschlagen: " + $_.Exception.Message) } +} + +# ── Main Menu ── +function Show-Menu { + while ($true) { + Write-Header + Write-Host ' Waehle eine Option:' -ForegroundColor White + Write-Host '' + Write-Host ' 1 Installieren / Installationsziel wählen' -ForegroundColor Cyan + Write-Host ' 2 Starten' -ForegroundColor Cyan + Write-Host ' 3 Update' -ForegroundColor Cyan + Write-Host ' 4 Reparieren' -ForegroundColor Cyan + Write-Host ' 5 Deinstallieren' -ForegroundColor Cyan + Write-Host ' 6 Status anzeigen' -ForegroundColor Cyan + Write-Host ' 13 Konfiguration ändern (Assistent)' -ForegroundColor Cyan + Write-Host ' 7 Lokales FLUX / ComfyUI installieren oder aktualisieren' -ForegroundColor Cyan + Write-Host ' 8 Lokales FLUX / ComfyUI Status' -ForegroundColor Cyan + Write-Host ' 9 Lokales FLUX / ComfyUI starten' -ForegroundColor Cyan + Write-Host ' 10 Lokales FLUX / ComfyUI stoppen' -ForegroundColor Cyan + Write-Host ' 11 Vorbereitete Operation anwenden' -ForegroundColor Cyan + Write-Host ' 12 Letzte Operation zuruecksetzen' -ForegroundColor Cyan + Write-Host ' 0 Beenden' -ForegroundColor DarkGray + Write-Host '' + $choice = Read-Host ' Auswahl' + + switch ($choice) { + '1' { Install-Vendoo } + '2' { Start-Vendoo } + '3' { Update-Vendoo } + '4' { Repair-Vendoo } + '5' { Uninstall-Vendoo } + '6' { Show-Status } + '7' { Install-LocalFlux } + '8' { Show-LocalFluxStatus } + '9' { Start-LocalFlux } + '10' { Stop-LocalFlux } + '11' { Apply-VendooPendingOperation } + '12' { Invoke-VendooRollback } + '13' { Configure-VendooConsole } + '0' { Write-Host ' Auf Wiedersehen!' -ForegroundColor Green; return } + default { Write-Warn 'Ungueltige Auswahl' } + } + Write-Host '' + Read-Host ' Druecke Enter um fortzufahren' + } +} + +Remove-LegacyTryOnComponents + +if (-not (Test-VendooPowerShellFiles)) { + Write-Host '' + Write-Err 'Vendoo Setup wurde wegen fehlerhafter oder fehlender Pflichtskripte beendet.' + exit 12 +} + +# Automatische Wiederaufnahme des exakt bestätigten Slice-22-Fehlzustands. +# Direkte CLI-Kommandos bleiben davon unberührt. +if ($args.Count -eq 0) { + Invoke-LocalFluxStartupRecovery +} + +# Handle CLI arguments for direct commands +if ($args.Count -gt 0) { + switch ($args[0]) { + 'install' { Install-Vendoo } + 'install-local' { $script:AutoStartAfterInstall = $true; Set-VendooInstallMode 'windows-local'; Install-VendooLocal } + 'install-docker' { Install-VendooDocker } + 'configure' { Configure-VendooConsole } + 'menu' { Show-Menu } + 'start' { Start-Vendoo } + 'update' { Update-Vendoo } + 'repair' { Repair-Vendoo } + 'uninstall' { Uninstall-Vendoo } + 'status' { Show-Status } + 'install-local-flux' { Install-LocalFlux } + 'flux-status' { Show-LocalFluxStatus } + 'flux-start' { Start-LocalFlux } + 'flux-stop' { Stop-LocalFlux } + 'apply-operation' { Apply-VendooPendingOperation } + 'rollback' { Invoke-VendooRollback } + default { Show-Menu } + } +} else { + Show-Menu +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.sh new file mode 100644 index 0000000..93e9b19 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/setup.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env sh +set -eu + +APP_VERSION="1.36.0" +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +MODE_FILE="$ROOT_DIR/.vendoo-install-mode" +ENV_FILE="$ROOT_DIR/.env" +ENV_EXAMPLE="$ROOT_DIR/.env.example" + +info() { printf '\033[36m[INFO]\033[0m %s\n' "$*"; } +ok() { printf '\033[32m[OK]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[WARN]\033[0m %s\n' "$*"; } +err() { printf '\033[31m[ERROR]\033[0m %s\n' "$*" >&2; } + +has_command() { command -v "$1" >/dev/null 2>&1; } + +get_mode() { + if [ -f "$MODE_FILE" ]; then tr -d '\r\n' < "$MODE_FILE"; else printf 'docker'; fi +} + +set_mode() { + printf '%s\n' "$1" > "$MODE_FILE" + chmod 600 "$MODE_FILE" 2>/dev/null || true +} + +compose_files() { + case "$(get_mode)" in + nas) printf '%s:%s' "$ROOT_DIR/compose.yaml" "$ROOT_DIR/compose.bind-mounts.example.yaml" ;; + vps) printf '%s' "$ROOT_DIR/compose.vps.yaml" ;; + *) printf '%s' "$ROOT_DIR/compose.yaml" ;; + esac +} + +compose() { + COMPOSE_FILE=$(compose_files) docker compose "$@" +} + +validate_env_value() { + case "$2" in + *'\n'*|*'\r'*) err "Ungültiger mehrzeiliger Wert für $1."; return 1 ;; + esac +} + +set_env() { + key=$1 + value=$2 + validate_env_value "$key" "$value" + tmp="$ENV_FILE.tmp.$$" + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + awk -v k="$key" -v v="$value" 'BEGIN{FS="="} $1==k{print k"="v; next} {print}' "$ENV_FILE" > "$tmp" + else + cat "$ENV_FILE" > "$tmp" + printf '\n%s=%s\n' "$key" "$value" >> "$tmp" + fi + chmod 600 "$tmp" 2>/dev/null || true + mv "$tmp" "$ENV_FILE" +} + +get_env() { + key=$1 + sed -n "s/^${key}=//p" "$ENV_FILE" 2>/dev/null | tail -n 1 +} + +generate_token() { + if has_command openssl; then openssl rand -hex 32 + elif [ -r /dev/urandom ]; then od -An -N32 -tx1 /dev/urandom | tr -d ' \n' + else date +%s | sha256sum | awk '{print $1}' + fi +} + +check_docker() { + if ! has_command docker; then + err "Docker ist auf diesem Zielsystem nicht installiert." + printf '%s\n' "Installiere Docker Engine und das Compose-Plugin nach der offiziellen Docker-Dokumentation und starte setup.sh danach erneut." + return 1 + fi + if ! docker compose version >/dev/null 2>&1; then + err "Das Docker-Compose-Plugin fehlt." + return 1 + fi + if ! docker info >/dev/null 2>&1; then + err "Docker ist installiert, aber der Dienst ist nicht erreichbar oder dein Benutzer hat keine Berechtigung." + return 1 + fi + ok "Docker und Docker Compose sind verfügbar." +} + +ensure_env() { + if [ ! -f "$ENV_FILE" ]; then + cp "$ENV_EXAMPLE" "$ENV_FILE" + chmod 600 "$ENV_FILE" 2>/dev/null || true + ok ".env wurde automatisch aus der sicheren Vorlage erstellt." + fi + token=$(get_env VENDOO_SETUP_TOKEN) + if [ -z "$token" ]; then + token=$(generate_token) + set_env VENDOO_SETUP_TOKEN "$token" + ok "Ein zufälliger Erst-Admin-Setup-Code wurde erzeugt." + fi + set_env VENDOO_REQUIRE_SETUP_TOKEN true +} + +read_default() { + prompt=$1 + default=$2 + printf '%s [%s]: ' "$prompt" "$default" >&2 + read -r value + if [ -z "$value" ]; then printf '%s' "$default"; else printf '%s' "$value"; fi +} + +read_yes_no() { + prompt=$1 + default=$2 + while :; do + printf '%s [%s]: ' "$prompt" "$default" + read -r answer + [ -n "$answer" ] || answer=$default + case "$answer" in + j|J|ja|JA|y|Y|yes|YES) return 0 ;; + n|N|nein|NEIN|no|NO) return 1 ;; + *) warn "Bitte ja oder nein eingeben." ;; + esac + done +} + +validate_port() { + case "$1" in ''|*[!0-9]*) return 1 ;; esac + [ "$1" -ge 1024 ] && [ "$1" -le 65535 ] +} + +validate_domain() { + case "$1" in + http://*|https://*|*/*|*:*|'') return 1 ;; + *.*) return 0 ;; + *) return 1 ;; + esac +} + +configure_wizard() { + ensure_env + printf '\nVendoo %s – geführte Installation\n\n' "$APP_VERSION" + printf ' 1 Docker auf Linux/macOS/Docker Desktop (nur lokal erreichbar)\n' + printf ' 2 NAS / Portainer / privates LAN (sichtbare Runtime-Ordner)\n' + printf ' 3 VPS / öffentlicher Server mit automatischem HTTPS\n' + printf ' 0 Abbrechen\n\n' + printf 'Installationsziel: ' + read -r choice + case "$choice" in + 1) mode=docker ;; + 2) mode=nas ;; + 3) mode=vps ;; + 0) return 1 ;; + *) err "Ungültige Auswahl."; return 1 ;; + esac + set_mode "$mode" + + current_port=$(get_env VENDOO_HOST_PORT) + [ -n "$current_port" ] || current_port=8124 + port=$(read_default "Port" "$current_port") + validate_port "$port" || { err "Port muss zwischen 1024 und 65535 liegen."; return 1; } + set_env PORT "$port" + set_env VENDOO_HOST_PORT "$port" + set_env VENDOO_COOKIE_SAMESITE lax + set_env VENDOO_ALLOW_ANY_EXTENSION_ORIGIN false + + case "$mode" in + docker) + set_env VENDOO_PUBLISH_ADDRESS 127.0.0.1 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY false + set_env VENDOO_COOKIE_SECURE auto + set_env PUBLIC_BASE_URL "" + ;; + nas) + mkdir -p "$ROOT_DIR/runtime/db" "$ROOT_DIR/runtime/uploads" "$ROOT_DIR/runtime/backups" "$ROOT_DIR/runtime/operations" "$ROOT_DIR/runtime/logs" "$ROOT_DIR/runtime/config" + chmod -R u+rwX "$ROOT_DIR/runtime" 2>/dev/null || true + set_env VENDOO_PUBLISH_ADDRESS 0.0.0.0 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY false + set_env VENDOO_COOKIE_SECURE auto + printf 'LAN-Basisadresse für QR-Uploads, z. B. http://192.168.1.20:%s (optional): ' "$port" + read -r public_url + case "$public_url" in + '') set_env PUBLIC_BASE_URL "" ;; + http://*|https://*) set_env PUBLIC_BASE_URL "${public_url%/}" ;; + *) err "Adresse muss mit http:// oder https:// beginnen."; return 1 ;; + esac + warn "NAS-Modus veröffentlicht Port $port im privaten LAN. Nicht direkt aus dem Internet freigeben." + ;; + vps) + while :; do + printf 'Öffentliche Domain ohne https://, z. B. vendoo.example.de: ' + read -r domain + validate_domain "$domain" && break + warn "Bitte einen vollständigen Domainnamen ohne Protokoll oder Pfad eingeben." + done + set_env VENDOO_DOMAIN "$domain" + set_env PUBLIC_BASE_URL "https://$domain" + set_env VENDOO_PUBLISH_ADDRESS 127.0.0.1 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY true + set_env VENDOO_COOKIE_SECURE true + set_env VENDOO_ALLOWED_HOSTS "$domain" + info "Caddy übernimmt HTTPS automatisch. DNS muss auf diesen Server zeigen; Ports 80 und 443 müssen erreichbar sein." + ;; + esac + + if read_yes_no "Lokales/externes FLUX aktivieren?" "nein"; then + set_env LOCAL_IMAGE_ENABLED true + default_flux=http://host.docker.internal:8188 + flux_url=$(read_default "FLUX/ComfyUI-Adresse" "$default_flux") + case "$flux_url" in http://*|https://*) set_env LOCAL_IMAGE_URL "${flux_url%/}" ;; *) err "FLUX-Adresse muss mit http:// oder https:// beginnen."; return 1 ;; esac + else + set_env LOCAL_IMAGE_ENABLED false + fi + + token=$(get_env VENDOO_SETUP_TOKEN) + printf '\nERST-ADMIN-SETUP-CODE:\n%s\n\n' "$token" + warn "Code bis zur ersten Anmeldung sicher aufbewahren. AI- und Marktplatz-Schlüssel werden später in Vendoo → Einstellungen gepflegt." + ok "Konfiguration gespeichert; keine Datei musste manuell bearbeitet werden." +} + +wait_ready() { + case "$(get_mode)" in + vps) url="https://$(get_env VENDOO_DOMAIN)/readyz" ;; + *) url="http://127.0.0.1:$(get_env VENDOO_HOST_PORT)/readyz" ;; + esac + if ! has_command curl; then warn "curl fehlt; Readiness später im Browser prüfen: $url"; return 0; fi + i=0 + while [ "$i" -lt 30 ]; do + if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then ok "Vendoo ist bereit: $url"; return 0; fi + i=$((i+1)); sleep 2 + done + warn "Readiness war noch nicht erfolgreich. Prüfe: compose logs --tail=200" + return 0 +} + +install_or_update() { + check_docker + configure_wizard || return 0 + info "Prüfe Compose-Konfiguration …" + compose config >/dev/null + ok "Compose-Konfiguration ist gültig." + info "Baue und starte Vendoo …" + compose build --pull + compose up -d --remove-orphans + compose ps + wait_ready +} + +start_app() { check_docker; compose up -d; compose ps; wait_ready; } +stop_app() { check_docker; compose down; } +update_app() { + check_docker + ensure_env + info "Erstelle vor dem Update automatisch ein Backup …" + backup_app + compose config >/dev/null + compose build --pull + compose up -d --remove-orphans + compose ps + wait_ready +} +status_app() { + check_docker + compose ps + printf '\nReadiness:\n' + case "$(get_mode)" in vps) url="https://$(get_env VENDOO_DOMAIN)/readyz" ;; *) url="http://127.0.0.1:$(get_env VENDOO_HOST_PORT)/readyz" ;; esac + if has_command curl; then curl -fsS "$url" || true; printf '\n'; else warn "curl fehlt; Readiness bitte im Browser prüfen: $url"; fi +} +backup_app() { + check_docker + COMPOSE_FILE=$(compose_files) "$ROOT_DIR/scripts/docker-backup.sh" +} +restore_app() { + printf 'Pfad zum Backup-TAR.GZ: ' + read -r backup + [ -f "$backup" ] || { err "Backup nicht gefunden."; return 1; } + check_docker + COMPOSE_FILE=$(compose_files) "$ROOT_DIR/scripts/docker-restore.sh" "$backup" --confirm + wait_ready +} +show_setup_code() { + ensure_env + printf '\nErst-Admin-Setup-Code:\n%s\n\n' "$(get_env VENDOO_SETUP_TOKEN)" +} + +menu() { + while :; do + printf '\nVendoo %s – Zero-Edit Deployment Assistant\n\n' "$APP_VERSION" + printf ' 1 Installieren / Konfiguration ändern\n' + printf ' 2 Starten\n' + printf ' 3 Stoppen\n' + printf ' 4 Status und Readiness\n' + printf ' 5 Aktualisieren (mit Sicherheitsbackup)\n' + printf ' 6 Backup erstellen\n' + printf ' 7 Backup wiederherstellen\n' + printf ' 8 Erst-Admin-Code anzeigen\n' + printf ' 0 Beenden\n\n' + printf 'Auswahl: ' + read -r choice + case "$choice" in + 1) install_or_update ;; + 2) start_app ;; + 3) stop_app ;; + 4) status_app ;; + 5) update_app ;; + 6) backup_app ;; + 7) restore_app ;; + 8) show_setup_code ;; + 0) exit 0 ;; + *) warn "Ungültige Auswahl." ;; + esac + done +} + +case "${1:-menu}" in + install|configure) install_or_update ;; + start) start_app ;; + stop) stop_app ;; + status) status_app ;; + update) update_app ;; + backup) backup_app ;; + restore) restore_app ;; + setup-code) show_setup_code ;; + menu) menu ;; + *) err "Unbekannter Befehl: $1"; exit 2 ;; +esac diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/build-release.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/build-release.mjs new file mode 100644 index 0000000..6efc321 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/build-release.mjs @@ -0,0 +1,54 @@ +import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { shouldSkipStagingEntry } from './prepare-git-staging.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const version = pkg.version; +const outputRoot = join(root, 'release-output'); +const packageName = `Vendoo_${version}`; +const stage = join(outputRoot, packageName); +const normalize = value => value.split(sep).join('/'); + +rmSync(outputRoot, { recursive: true, force: true }); +mkdirSync(stage, { recursive: true }); + +function copyTree(src, dest, rel = '') { + for (const name of readdirSync(src)) { + const source = join(src, name); + const childRel = rel ? join(rel, name) : name; + const stat = lstatSync(source); + if (shouldSkipStagingEntry(childRel, stat)) continue; + const target = join(dest, name); + if (stat.isDirectory()) { + mkdirSync(target, { recursive: true }); + copyTree(source, target, childRel); + } else if (stat.isFile()) { + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target); + } + } +} +copyTree(root, stage); + +const checksums = []; +function hashTree(dir) { + for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b))) { + const abs = join(dir, name); + const stat = lstatSync(abs); + if (stat.isDirectory()) hashTree(abs); + else { + const rel = normalize(relative(stage, abs)); + if (rel === 'FILE_CHECKSUMS_SHA256.txt') continue; + const digest = createHash('sha256').update(readFileSync(abs)).digest('hex'); + checksums.push(`${digest} ${rel}`); + } + } +} +hashTree(stage); +writeFileSync(join(stage, 'FILE_CHECKSUMS_SHA256.txt'), checksums.join('\n') + '\n', 'utf8'); +writeFileSync(join(outputRoot, 'RELEASE_NAME.txt'), packageName + '\n', 'utf8'); +console.log(`Release-Staging erstellt: ${stage}`); +console.log(`${checksums.length} Dateien mit SHA-256 erfasst.`); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/generate-design-tokens.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/generate-design-tokens.mjs new file mode 100644 index 0000000..5ca41c5 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/generate-design-tokens.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sourcePath = join(root, 'public', 'design-system', 'tokens', 'source.json'); +const cssPath = join(root, 'public', 'design-system', 'tokens.css'); +const contractPath = join(root, 'public', 'design-system', 'theme-contract.json'); +const source = JSON.parse(readFileSync(sourcePath, 'utf8')); +const failures = []; + +if (source.schemaVersion !== 1) failures.push('schemaVersion muss 1 sein'); +if (!/^\d+\.\d+\.\d+$/.test(String(source.contractVersion || ''))) failures.push('contractVersion ist ungültig'); +if (!Array.isArray(source.themes) || !source.themes.includes('light') || !source.themes.includes('dark')) failures.push('light und dark müssen definiert sein'); + +const cssVars = new Set(); +for (const [name, token] of Object.entries(source.tokens || {})) { + if (!/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/.test(name)) failures.push(`Ungültiger Tokenname: ${name}`); + if (!/^--vd-[a-z0-9-]+$/.test(String(token.css || ''))) failures.push(`Ungültige CSS-Variable: ${name}`); + if (cssVars.has(token.css)) failures.push(`Doppelte CSS-Variable: ${token.css}`); + cssVars.add(token.css); + for (const theme of source.themes) if (typeof token[theme] !== 'string' || !token[theme].trim()) failures.push(`${name} fehlt in Theme ${theme}`); +} +if (failures.length) { + console.error(`Design-Token-Generierung fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} + +function block(selector, theme) { + const lines = Object.values(source.tokens).map(token => ` ${token.css}: ${token[theme]};`); + return `${selector} {\n${lines.join('\n')}\n}`; +} + +const css = `/* Automatisch generiert aus design-system/tokens/source.json. Nicht direkt bearbeiten. */\n` + + `/* Contract ${source.contractVersion} */\n` + + `${block(':root', 'light')}\n\n${block('[data-theme="dark"]', 'dark')}\n\n${block('[data-theme="contrast"]', 'contrast')}\n\n` + + `@media (prefers-reduced-motion: reduce) {\n :root { --vd-motion-fast: 0ms; --vd-motion-normal: 0ms; }\n}\n`; +writeFileSync(cssPath, css, 'utf8'); + +const contract = { + schemaVersion: source.schemaVersion, + contractVersion: source.contractVersion, + themes: source.themes, + tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, { + css: token.css, + type: token.type, + customizable: Boolean(token.customizable), + label: token.label || name, + group: token.group || name.split('.')[0], + ...(token.min !== undefined ? { min: token.min } : {}), + ...(token.max !== undefined ? { max: token.max } : {}), + }])), +}; +writeFileSync(contractPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8'); +console.log(`Design Tokens erzeugt: ${Object.keys(source.tokens).length} Tokens, ${source.themes.length} Themes.`); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/prepare-git-staging.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/prepare-git-staging.mjs new file mode 100644 index 0000000..6277283 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/prepare-git-staging.mjs @@ -0,0 +1,106 @@ +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, +} from 'node:fs'; +import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const normalize = value => value.split(sep).join('/').replace(/^\.\//, ''); +const allowedEnvTemplates = new Set(['.env.example', '.env.docker.example']); +const skippedTopLevelDirs = new Set([ + 'node_modules', 'runtime', 'docker-data', 'uploads', 'backups', 'logs', + 'sessions', 'tmp', 'temp', 'release-output', 'operations', 'updates', +]); +const skippedLocalAiDirs = new Set(['local-ai/comfyui', 'local-ai/downloads', 'local-ai/extract-temp']); +const skippedExactFiles = new Set([ + '.vendoo-install-mode', '.vendoo-github-state.json', + 'pending-operation.json', 'last-operation.json', 'first-admin-code.txt', +]); +const skippedExtensions = new Set([ + '.db', '.sqlite', '.sqlite3', '.log', '.key', '.pem', '.pfx', '.p12', +]); + +export function shouldSkipStagingEntry(relativePath, stat) { + const rel = normalize(relativePath); + const parts = rel.split('/'); + const name = basename(rel); + const lower = rel.toLowerCase(); + + if (!rel || rel === '.git' || lower.startsWith('.git/')) return true; + if (skippedTopLevelDirs.has(parts[0].toLowerCase())) return true; + if ([...skippedLocalAiDirs].some(dir => lower === dir || lower.startsWith(`${dir}/`))) return true; + if (/^\.env(?:\..+)?$/i.test(name) && !allowedEnvTemplates.has(rel)) return true; + if (skippedExactFiles.has(name.toLowerCase())) return true; + if (/\.(?:db|sqlite|sqlite3)-(?:wal|shm)$/i.test(name)) return true; + if (skippedExtensions.has(extname(name).toLowerCase())) return true; + if (stat.isSymbolicLink()) return true; + return false; +} + +function cleanDestination(destination) { + if (!existsSync(join(destination, '.git'))) { + throw new Error(`Ziel ist kein geklontes Git-Repository: ${destination}`); + } + for (const name of readdirSync(destination)) { + if (name === '.git') continue; + rmSync(join(destination, name), { recursive: true, force: true }); + } +} + +export function prepareGitStaging(sourceRoot, destinationRoot) { + const source = resolve(sourceRoot); + const destination = resolve(destinationRoot); + if (source === destination) throw new Error('Quell- und Zielordner dürfen nicht identisch sein.'); + if (!existsSync(source)) throw new Error(`Quellordner fehlt: ${source}`); + if (!existsSync(destination)) throw new Error(`Zielordner fehlt: ${destination}`); + + cleanDestination(destination); + let copied = 0; + let skipped = 0; + + function walk(current) { + for (const name of readdirSync(current)) { + const absolute = join(current, name); + const rel = normalize(relative(source, absolute)); + const stat = lstatSync(absolute); + if (shouldSkipStagingEntry(rel, stat)) { + skipped += 1; + continue; + } + const target = join(destination, rel); + if (stat.isDirectory()) { + mkdirSync(target, { recursive: true }); + walk(absolute); + } else if (stat.isFile()) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(absolute, target); + try { chmodSync(target, stat.mode & 0o777); } catch {} + copied += 1; + } + } + } + + walk(source); + return { source, destination, copied, skipped }; +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''; +if (invokedPath === fileURLToPath(import.meta.url)) { + const [, , source, destination] = process.argv; + if (!source || !destination) { + console.error('Aufruf: node tools/prepare-git-staging.mjs '); + process.exit(64); + } + try { + const result = prepareGitStaging(source, destination); + console.log(`Git-Staging vorbereitet: ${result.copied} Quelldateien kopiert; ${result.skipped} lokale Einträge ausgeschlossen.`); + } catch (error) { + console.error(`[FEHLER] Git-Staging konnte nicht vorbereitet werden: ${error.message}`); + process.exit(1); + } +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-config-store.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-config-store.mjs new file mode 100644 index 0000000..0993e25 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-config-store.mjs @@ -0,0 +1,23 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-config-store-')); +const path = join(temp, 'config', 'vendoo.env'); +process.env.VENDOO_CONFIG_PATH = path; + +try { + const mod = await import(`../lib/config-store.mjs?test=${Date.now()}`); + mod.updateRuntimeConfig({ OPENAI_API_KEY: 'test-only-value', PUBLIC_BASE_URL: 'https://vendoo.example.invalid' }); + const content = readFileSync(path, 'utf8'); + if (!content.includes('OPENAI_API_KEY=test-only-value')) throw new Error('OPENAI_API_KEY wurde nicht gespeichert.'); + if (!content.includes('PUBLIC_BASE_URL=https://vendoo.example.invalid')) throw new Error('PUBLIC_BASE_URL wurde nicht gespeichert.'); + if (process.env.OPENAI_API_KEY !== 'test-only-value') throw new Error('process.env wurde nicht synchronisiert.'); + let rejected = false; + try { mod.updateRuntimeConfig({ SMTP_PASS: 'line1\nline2' }); } catch { rejected = true; } + if (!rejected) throw new Error('Mehrzeiliger Secret-Wert wurde nicht abgelehnt.'); + if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0) throw new Error('Konfigurationsdatei ist nicht auf 0600 begrenzt.'); + console.log('Konfigurationsspeicher erfolgreich geprüft: persistent, restriktiv und newline-geschützt.'); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-db-migrations.py b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-db-migrations.py new file mode 100644 index 0000000..aabf1b6 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-db-migrations.py @@ -0,0 +1,136 @@ +from pathlib import Path +import re, sqlite3, tempfile, json, sys + +APP = Path(__file__).resolve().parents[1] +BASE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else None + +def exec_db_exec_blocks(conn, source): + text = Path(source).read_text(encoding='utf-8') + blocks = re.findall(r"db\.exec\(`(.*?)`\)", text, flags=re.S) + applied = 0 + for sql in blocks: + if '${' in sql: + continue + try: + conn.executescript(sql) + applied += 1 + except sqlite3.DatabaseError: + # db.mjs intentionally catches additive ALTER errors. + pass + if 'operations-center-1.30.0' in text: + conn.execute("INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('operations-center-1.30.0','1.30.0','Backup, Restore, Update Center und Extension-Diagnose')") + return applied + +def columns(conn, table): + return {row[1] for row in conn.execute(f'PRAGMA table_info({table})')} + +def verify(conn): + required_tables = { + 'flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions', + 'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports', + 'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings', + 'update_operations', 'system_migrations', 'extension_clients', 'users', 'sessions', 'audit_log', 'resource_locks', 'theme_profiles', 'user_theme_preferences' + } + existing_tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + missing_tables = required_tables - existing_tables + checks = { + 'flux_images': {'favorite','job_id','variant_index','prompt_mode','parameters_json'}, + 'flux_prompt_jobs': {'archived_at'}, + 'image_edit_versions': {'root_path','source_path','output_path','context','operations_json','width','height','format','quality','file_size','user_id','created_at'}, + 'image_batch_jobs': {'name','status','phase','source_count','profile_count','total_items','completed_items','failed_items','cancelled_items','pause_requested','cancel_requested','settings_json','created_at','updated_at'}, + 'image_batch_job_items': {'job_id','source_path','profile_id','profile_key','profile_name','status','phase','settings_json','output_path','error_message','created_at','updated_at'}, + 'quality_reports': {'listing_id','score','grade','ready','blockers','warnings','recommendations','report_json','analyzed_at'}, + 'publishing_jobs': {'id','listing_id','platform','mode','action','status','phase','idempotency_key','external_id','external_url','offer_id','retries','max_retries','next_attempt_at','claimed_by','claimed_at','error_code','error_message','payload_json','result_json','created_at','started_at','completed_at','updated_at'}, + 'publishing_events': {'id','job_id','listing_id','platform','level','event_type','message','details_json','created_at'}, + 'publish_log': {'offer_id','sync_status','last_synced_at','ended_at','updated_at'}, + 'operation_backups': {'id','kind','label','file_name','file_path','file_size','sha256','status','verified_at','options_json','manifest_json','created_at'}, + 'operation_settings': {'key','value','updated_at'}, + 'update_operations': {'id','version_from','version_to','source_name','archive_path','sha256','status','details_json','created_at','applied_at','error_message'}, + 'system_migrations': {'id','migration_key','app_version','description','applied_at'}, + 'extension_clients': {'client_id','browser','version','platform','server_url','user_agent','capabilities_json','first_seen','last_seen'}, + 'users': {'id','email','name','password_hash','role','active','avatar_color','last_active_at','failed_login_count','locked_until','password_changed_at','created_at','invited_by'}, + 'sessions': {'id','user_id','token_hash','ip','user_agent','expires_at','last_seen_at','revoked_at','created_at'}, + 'audit_log': {'id','user_id','user_email','action','target_type','target_id','details','ip','created_at'}, + 'resource_locks': {'id','resource_type','resource_id','user_id','token_hash','acquired_at','renewed_at','expires_at','client_label'}, + 'theme_profiles': {'id','name','base_theme','values_json','created_by','created_at','updated_at'}, + 'user_theme_preferences': {'user_id','theme_id','density','reduced_motion','updated_at'}, + } + missing_columns = {table: sorted(required - columns(conn, table)) for table, required in checks.items() if required - columns(conn, table)} + if missing_tables or missing_columns: + raise AssertionError({'missing_tables': sorted(missing_tables), 'missing_columns': missing_columns}) + +with tempfile.TemporaryDirectory() as temp: + fresh = sqlite3.connect(Path(temp) / 'fresh.db') + fresh.executescript((APP / 'db/schema.sql').read_text(encoding='utf-8')) + fresh_blocks = exec_db_exec_blocks(fresh, APP / 'lib/db.mjs') + verify(fresh) + + existing = sqlite3.connect(Path(temp) / 'existing.db') + existing.executescript((APP / 'db/schema.sql').read_text(encoding='utf-8')) + if BASE: + exec_db_exec_blocks(existing, BASE / 'lib/db.mjs') + existing.execute("INSERT INTO flux_images (prompt,image_path,width,height,seed,steps,style) VALUES ('Bestand','ai-generated/existing.png',768,768,42,4,'none')") + existing.commit() + existing_blocks = exec_db_exec_blocks(existing, APP / 'lib/db.mjs') + verify(existing) + row = existing.execute("SELECT prompt,image_path,favorite FROM flux_images WHERE image_path='ai-generated/existing.png'").fetchone() + if row != ('Bestand','ai-generated/existing.png',0): + raise AssertionError(f'Bestandsdaten verändert: {row!r}') + + jobs = [('job-complete', 'completed'), ('job-failed', 'failed'), ('job-active', 'running')] + insert_job = """INSERT INTO flux_prompt_jobs + (id,prompt,final_prompt,width,height,steps,style,profile,prompt_mode,assistant,variants,seed_base,seed_lock,status,phase,settings_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""" + for job_id, status in jobs: + existing.execute(insert_job, (job_id,'Test','Test',768,768,6,'none','balanced','precise',1,1,42,0,status,status,'{}')) + existing.commit() + existing.execute("UPDATE flux_prompt_jobs SET archived_at=datetime('now') WHERE id IN ('job-complete','job-failed','job-active') AND status NOT IN ('queued','running','cancelling')") + archived = {row[0] for row in existing.execute("SELECT id FROM flux_prompt_jobs WHERE archived_at IS NOT NULL")} + if archived != {'job-complete','job-failed'}: + raise AssertionError(f'Archivschutz inkorrekt: {archived!r}') + + existing.execute("INSERT INTO listings (title, platform, ai_provider, photos, tags) VALUES ('Publishing Test', 'ebay-de', 'openai', '[]', '[]')") + listing_id = existing.execute("SELECT last_insert_rowid()").fetchone()[0] + existing.execute("""INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) + VALUES ('pub-job-1', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')""", (listing_id,)) + existing.execute("""INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message) + VALUES ('pub-job-1', ?, 'ebay-de', 'info', 'created', 'Auftrag erstellt')""", (listing_id,)) + duplicate_guard = False + try: + existing.execute("""INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) + VALUES ('pub-job-2', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')""", (listing_id,)) + except sqlite3.IntegrityError: + duplicate_guard = True + if not duplicate_guard: + raise AssertionError('Idempotency-Key verhindert keinen doppelten Auftrag') + existing.execute("UPDATE publishing_jobs SET status='running', phase='publish' WHERE id='pub-job-1'") + existing.execute("UPDATE publishing_jobs SET status='queued', phase='recovered' WHERE status='running'") + recovered = existing.execute("SELECT status, phase FROM publishing_jobs WHERE id='pub-job-1'").fetchone() + if recovered != ('queued', 'recovered'): + raise AssertionError(f'Publishing-Recovery fehlgeschlagen: {recovered!r}') + + existing.execute("INSERT INTO operation_settings (key,value) VALUES ('auto_backup_enabled','1')") + existing.execute("INSERT INTO operation_backups (id,kind,file_name,file_path,status) VALUES ('backup-1','manual','test.zip','/tmp/test.zip','verified')") + existing.execute("INSERT INTO update_operations (id,version_from,version_to,archive_path,status) VALUES ('update-1','1.29.0','1.30.0','/tmp/update.zip','staged')") + existing.execute("INSERT INTO extension_clients (client_id,browser,version,platform) VALUES ('client-1','chrome','1.9.0','popup')") + migration = existing.execute("SELECT app_version FROM system_migrations WHERE migration_key='operations-center-1.30.0'").fetchone() + if migration != ('1.30.0',): + raise AssertionError(f'Operations-Migration fehlt: {migration!r}') + extension = existing.execute("SELECT browser,version FROM extension_clients WHERE client_id='client-1'").fetchone() + if extension != ('chrome','1.9.0'): + raise AssertionError(f'Extension-Client fehlerhaft: {extension!r}') + + print(json.dumps({ + 'ok': True, + 'fresh_db_exec_blocks': fresh_blocks, + 'existing_db_exec_blocks': existing_blocks, + 'existing_row_preserved': True, + 'publishing_hardening': { + 'tables': ['publishing_jobs','publishing_events'], + 'idempotency_key_unique': True, + 'restart_recovery': True, + 'events_persisted': True, + }, + 'operations_center': {'backup_tables': True, 'update_tables': True, 'extension_heartbeat': True, 'migration_recorded': True}, + 'tables': ['flux_images','flux_prompt_jobs','flux_prompt_job_variants','image_edit_versions','image_batch_profiles','image_batch_jobs','image_batch_job_items','quality_reports','publishing_jobs','publishing_events','operation_backups','operation_settings','update_operations','system_migrations','extension_clients','theme_profiles','user_theme_preferences'], + }, indent=2)) diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.32.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.32.0.mjs new file mode 100644 index 0000000..a397297 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.32.0.mjs @@ -0,0 +1,70 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const dockerfile = read('Dockerfile'); +const compose = read('compose.yaml'); +const env = read('.env.example'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const setup = read('setup.ps1'); +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.32.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.32.0"', 'style.css?v=1.32.0', 'app.js?v=1.32.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.32.0';")) failures.push('UI-Build ist nicht 1.32.0'); +for (const marker of ["$Version = '1.32.0'", '?build=1.32.0']) expect(setup, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + "app.get('/healthz'", "app.get('/readyz'", 'gracefulShutdown', "process.once('SIGTERM'", + 'verifyRuntimeWritable', 'VENDOO_SETUP_TOKEN', 'VENDOO_EXTENSION_ORIGINS', 'setup_required: true', + "app.use('/uploads', requireAuth", 'allowed.includes(ext) && isImageMime', +]) expect(server, marker); + +for (const marker of ['VENDOO_DATA_DIR', 'VENDOO_DB_PATH', 'VENDOO_UPLOADS_DIR', 'VENDOO_BACKUP_DIR', 'VENDOO_OPERATIONS_DIR']) expect(runtimePaths, marker); + + +if (server.includes('if (isSetupMode()) return next();')) failures.push('Unsichere globale Auth-Ausnahme im Setup-Modus ist noch vorhanden'); + +for (const forbiddenMarker of [ + "resolve(__dirname, 'uploads')", "resolve(__dirname, 'uploads',", "join(__dirname, 'uploads',", + "join(__dirname, 'logs'", "join(root, 'uploads')", "join(root, 'db')", +]) if (server.includes(forbiddenMarker)) failures.push(`Server enthält noch codegebundenen Laufzeitpfad: ${forbiddenMarker}`); +for (const marker of ['FROM node:22-bookworm-slim', 'npm ci --omit=dev', 'USER node', 'HEALTHCHECK', 'ENTRYPOINT']) expect(dockerfile, marker); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', 'vendoo_db:', 'vendoo_uploads:', 'vendoo_backups:', '/readyz', 'pids_limit: 256', 'max-size: "10m"']) expect(compose, marker); +for (const marker of ['VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'VENDOO_COOKIE_SAMESITE=', 'VENDOO_EXTENSION_ORIGINS=']) expect(env, marker); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child.replaceAll('\\', '/')); + } + return out; +} +const forbidden = walk(root).filter(rel => + /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|operations|logs|runtime|data)(\/|$)/i.test(rel) + && rel !== '.env.example' +); +if (forbidden.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbidden.slice(0, 10).join(', ')}`); + +if (failures.length) { + console.error(`Deployment-Gate 1.32.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.32.0 erfolgreich: Versionen, Pfadtrennung, Container-Härtung, Readiness, Shutdown, Setup-Schutz und Datenhygiene geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.33.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.33.0.mjs new file mode 100644 index 0000000..d65b55c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.33.0.mjs @@ -0,0 +1,92 @@ +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const auth = read('lib/auth.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const dockerfile = read('Dockerfile'); +const compose = read('compose.yaml'); +const env = read('.env.example'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const setupPs = read('setup.ps1'); +const setupSh = read('setup.sh'); +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.33.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.33.0"', 'style.css?v=1.33.0', 'app.js?v=1.33.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.33.0';")) failures.push('UI-Build ist nicht 1.33.0'); +for (const marker of ["$Version = '1.33.0'", '?build=1.33.0']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + "app.get('/healthz'", "app.get('/readyz'", 'gracefulShutdown', "process.once('SIGTERM'", + 'verifyRuntimeWritable', 'VENDOO_SETUP_TOKEN', 'VENDOO_EXTENSION_ORIGINS', 'setup_required: true', + "app.use('/uploads', requireAuth", 'allowed.includes(ext) && isImageMime', +]) expect(server, marker); +for (const marker of ['VENDOO_DATA_DIR', 'VENDOO_DB_PATH', 'VENDOO_UPLOADS_DIR', 'VENDOO_BACKUP_DIR', 'VENDOO_OPERATIONS_DIR']) expect(runtimePaths, marker); +forbid(server, 'if (isSetupMode()) return next();', 'unsichere globale Setup-Auth-Ausnahme'); + +for (const marker of ['FROM node:22-bookworm-slim', 'npm ci --omit=dev', 'USER node', 'HEALTHCHECK', 'ENTRYPOINT']) expect(dockerfile, marker); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', 'vendoo_db:', 'vendoo_uploads:', 'vendoo_backups:', '/readyz', 'pids_limit: 256', 'max-size: "10m"', 'host.docker.internal:host-gateway']) expect(compose, marker); +for (const marker of ['VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'VENDOO_COOKIE_SAMESITE=', 'VENDOO_EXTENSION_ORIGINS=']) expect(env, marker); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); + +for (const marker of [ + "Wo soll Vendoo betrieben werden?", 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'NAS / Portainer / Docker-Server', 'VPS / öffentlicher Linux-Server', 'Install-VendooDocker', + "Get-VendooInstallMode", "Set-VendooInstallMode 'docker'", +]) expect(setupPs, marker, `Windows-Deployment-Assistent ${marker}`); + +if (!existsSync(join(root, 'setup.sh'))) failures.push('setup.sh fehlt'); +for (const marker of [ + 'Multiplatform Deployment Assistant', 'Docker auf diesem Linux-/Docker-Host', + 'NAS / Portainer / Docker-Host', 'VPS / öffentlicher Server hinter HTTPS-Reverse-Proxy', + 'compose build --pull', 'VENDOO_SETUP_TOKEN', 'docker-backup.sh', 'docker-restore.sh', +]) expect(setupSh, marker, `Linux-Deployment-Assistent ${marker}`); + +for (const marker of [ + 'id="flux-studio-reset"', 'Arbeitsbereich zurücksetzen', 'id="edit-user-email"', + 'type="button" class="primary-btn" id="edit-user-save-btn"', +]) expect(html, marker, `UI-Fix ${marker}`); +for (const marker of [ + 'function resetFluxStudioWorkspace', "renderFluxStudioPreview(null)", + "document.getElementById('flux-studio-reset')", 'data-user-edit=', + 'adminUsersCache', "button.textContent = 'Wird gespeichert …'", +]) expect(app, marker, `Frontend-Fix ${marker}`); +forbid(app, 'if (!state.fluxStudioCurrent && state.fluxStudioHistory[0])', 'automatische Auswahl des ersten FLUX-Historienbilds'); +forbid(app, 'onclick="openEditUser(', 'fragile Inline-Benutzerbearbeitung'); +for (const marker of ['return getUsers().find(user => Number(user.id) === Number(id)) || null;']) expect(auth, marker, 'sanitisierte Benutzerantwort nach Update'); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child.replaceAll('\\', '/')); + } + return out; +} +const forbiddenRuntime = walk(root).filter(rel => + /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|operations|logs|runtime|data)(\/|$)/i.test(rel) + && rel !== '.env.example' +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +if (failures.length) { + console.error(`Deployment-Gate 1.33.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.33.0 erfolgreich: Multiplatform-Assistent, Container-Härtung, leerer FLUX-Start, Reset und stabile Benutzerbearbeitung geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.0.mjs new file mode 100644 index 0000000..a8f9c5b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.0.mjs @@ -0,0 +1,109 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.0"', 'style.css?v=1.34.0', 'app.js?v=1.34.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.0';")) failures.push('UI-Build ist nicht 1.34.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.0'", '?build=1.34.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.0-initial-import', 'verify-git-safety.mjs', 'robocopy.exe', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/3']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.0 erfolgreich: Zero-Edit-Installer, VPS-HTTPS, Git-Sicherheitsgate, CI, Release-Paket und GHCR-Workflow geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.2.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.2.mjs new file mode 100644 index 0000000..4897800 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.2.mjs @@ -0,0 +1,116 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.2') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.2"', 'style.css?v=1.34.2', 'app.js?v=1.34.2']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.2';")) failures.push('UI-Build ist nicht 1.34.2'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.2'", '?build=1.34.2', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.2 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.2-initial-import', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/5']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.2 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.2 erfolgreich: gemeinsamer Git-Staging-Builder, vollständige Windows-Diagnose, Arbeitsbaumscan, Regressionstests, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.3.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.3.mjs new file mode 100644 index 0000000..73e87ed --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.34.3.mjs @@ -0,0 +1,116 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.3') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.3"', 'style.css?v=1.34.3', 'app.js?v=1.34.3']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.3';")) failures.push('UI-Build ist nicht 1.34.3'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.3'", '?build=1.34.3', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.3 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.3-initial-import', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/6']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.3 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.3 erfolgreich: lokale Update-/Slice-Archive vollständig aus Git-Staging und Release-Paketen ausgeschlossen; aktive Installationsskripte, Arbeitsbaumscan, Regressionstests, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.35.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.35.0.mjs new file mode 100644 index 0000000..7d13ab9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.35.0.mjs @@ -0,0 +1,129 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); +const architectureGate = read('tools/verify-platform-architecture-1.35.0.mjs'); +const tokenSource = read('public/design-system/tokens/source.json'); +const tokenCss = read('public/design-system/tokens.css'); +const moduleSchema = read('app/contracts/module.schema.json'); +const boundaries = read('app/architecture-boundaries.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.35.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.35.0"', 'style.css?v=1.35.0', 'app.js?v=1.35.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.35.0';")) failures.push('UI-Build ist nicht 1.35.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.35.0'", '?build=1.35.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.35.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.35.0-platform-kernel', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/7']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); + +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) expect(read('server.mjs'), marker, `Platform-Kernel ${marker}`); +for (const marker of ['Platform-Architecture-Gate 1.35.0 erfolgreich', 'MODULE_DEPENDENCY_CYCLE', 'ROUTE_POLICY_REQUIRED', 'THEME_VALUE_INVALID']) expect(architectureGate, marker, `Architektur-Gate ${marker}`); +for (const marker of ['contractVersion', 'color.brand', 'density.controlHeight', 'motion.normal']) expect(tokenSource, marker, `Tokenquelle ${marker}`); +for (const marker of [':root {', '[data-theme="dark"]', '[data-theme="contrast"]', '--vd-brand:']) expect(tokenCss, marker, `Token-CSS ${marker}`); +for (const marker of ['Vendoo Module Manifest', 'legacy-bridge', 'extension-host']) expect(moduleSchema, marker, `Modulschema ${marker}`); +for (const marker of ['modular-monolith', 'denyImplicitRoutes', 'thirdPartyExtensionsInMainProcess', 'strangler']) expect(boundaries, marker, `Architekturgrenzen ${marker}`); +for (const required of ['app/kernel/platform-kernel.mjs', 'app/kernel/module-registry.mjs', 'app/kernel/route-registry.mjs', 'app/kernel/policy-engine.mjs', 'app/core/themes/theme-contract.mjs', 'public/design-system/theme-runtime.js', 'public/design-system/theme-contract.json', 'public/core/frontend-module-registry.js', 'public/core/safe-dom.js', 'public/core/api-client.js', 'docs/architecture/MODULE_SYSTEM_1_35_0.md', 'docs/architecture/DESIGN_SYSTEM_1_35_0.md', 'docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md']) if (!existsSync(join(root, required))) failures.push(`Architektur-Pflichtdatei fehlt: ${required}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.35.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.35.0 erfolgreich: Plattformkernel, Modul-/Policy-/Routenverträge, Design Tokens, Legacy-Bridges, Update-Archiv-Ausschluss, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.36.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.36.0.mjs new file mode 100644 index 0000000..a29e84b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-deployment-1.36.0.mjs @@ -0,0 +1,133 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); +const architectureGate = read('tools/verify-platform-architecture-1.36.0.mjs'); +const themeGate = read('tools/verify-theme-manager-1.36.0.mjs'); +const themeManager = read('public/modules/theme-manager/theme-manager.js'); +const tokenSource = read('public/design-system/tokens/source.json'); +const tokenCss = read('public/design-system/tokens.css'); +const moduleSchema = read('app/contracts/module.schema.json'); +const boundaries = read('app/architecture-boundaries.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.36.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.36.0"', 'style.css?v=1.36.0', 'app.js?v=1.36.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.36.0';")) failures.push('UI-Build ist nicht 1.36.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.36.0'", '?build=1.36.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.36.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:architecture', 'npm run verify:themes', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:architecture', 'npm run verify:themes', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.36.0-theme-manager', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/9']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); + +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) expect(read('server.mjs'), marker, `Platform-Kernel ${marker}`); +for (const marker of ['Platform-Architecture-Gate 1.36.0 erfolgreich', 'MODULE_DEPENDENCY_CYCLE', 'ROUTE_POLICY_REQUIRED']) expect(architectureGate, marker, `Architektur-Gate ${marker}`); +for (const marker of ['Theme-Manager-Gate 1.36.0 erfolgreich', 'THEME_VALUE_INVALID', 'theme_profiles', 'user_theme_preferences']) expect(themeGate, marker, `Theme-Gate ${marker}`); +for (const marker of ['/api/themes/preference', '/api/themes/system-default', 'previewTheme']) expect(themeManager, marker, `Theme Manager ${marker}`); +for (const marker of ['contractVersion', 'color.brand', 'density.controlHeight', 'motion.normal']) expect(tokenSource, marker, `Tokenquelle ${marker}`); +for (const marker of [':root {', '[data-theme="dark"]', '[data-theme="contrast"]', '--vd-brand:']) expect(tokenCss, marker, `Token-CSS ${marker}`); +for (const marker of ['Vendoo Module Manifest', 'legacy-bridge', 'extension-host']) expect(moduleSchema, marker, `Modulschema ${marker}`); +for (const marker of ['modular-monolith', 'denyImplicitRoutes', 'thirdPartyExtensionsInMainProcess', 'strangler']) expect(boundaries, marker, `Architekturgrenzen ${marker}`); +for (const required of ['app/kernel/platform-kernel.mjs', 'app/kernel/module-registry.mjs', 'app/kernel/route-registry.mjs', 'app/kernel/policy-engine.mjs', 'app/core/themes/theme-contract.mjs', 'public/design-system/theme-runtime.js', 'public/design-system/theme-contract.json', 'public/core/frontend-module-registry.js', 'public/core/safe-dom.js', 'public/core/api-client.js', 'docs/architecture/MODULE_SYSTEM_1_35_0.md', 'docs/architecture/DESIGN_SYSTEM_1_35_0.md', 'docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md', 'docs/architecture/THEME_MANAGER_1_36_0.md']) if (!existsSync(join(root, required))) failures.push(`Architektur-Pflichtdatei fehlt: ${required}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.36.0 erfolgreich: Plattformkernel, natives Theme-Modul, Theme Manager, Accessibility-Gates, Design Tokens, Legacy-Bridges, Update-Archiv-Ausschluss, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety-regression.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety-regression.mjs new file mode 100644 index 0000000..7b779aa --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety-regression.mjs @@ -0,0 +1,40 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const tempRoot = mkdtempSync(join(tmpdir(), 'vendoo-git-gate-regression-')); +const testRoot = join(tempRoot, 'repo'); +mkdirSync(join(testRoot, 'tools'), { recursive: true }); +cpSync(join(sourceRoot, 'tools', 'verify-git-safety.mjs'), join(testRoot, 'tools', 'verify-git-safety.mjs')); +writeFileSync(join(testRoot, '.gitignore'), '.env\nnode_modules/\nruntime/\noperations/pending-operation.json\nlocal-ai/comfyui/\n.vendoo-install-mode\n', 'utf8'); +writeFileSync(join(testRoot, 'package.json'), '{"type":"module"}\n', 'utf8'); + +try { + execFileSync('git', ['init'], { cwd: testRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '.gitignore', 'package.json', 'tools/verify-git-safety.mjs'], { cwd: testRoot, stdio: 'ignore' }); + + // Entscheidend: Diese Dateien bleiben absichtlich unversioniert. + const openAiLike = 'sk-' + 'this-is-a-regression-test-secret-1234567890'; + writeFileSync(join(testRoot, '.env.local'), `OPENAI_API_KEY=${openAiLike}\n`, 'utf8'); + const githubLike = 'github_' + 'pat_' + 'abcdefghijklmnopqrstuvwxyz0123456789TEST'; + writeFileSync(join(testRoot, 'untracked-secret.txt'), `${githubLike}\n`, 'utf8'); + + const blocked = spawnSync(process.execPath, ['tools/verify-git-safety.mjs'], { cwd: testRoot, encoding: 'utf8' }); + if (blocked.status === 0) throw new Error('Gate hat unversionierte Secret-Dateien nicht blockiert.'); + const report = `${blocked.stdout || ''}\n${blocked.stderr || ''}`; + if (!report.includes('.env.local') || !report.includes('untracked-secret.txt')) { + throw new Error(`Gate-Bericht nennt nicht alle unversionierten Treffer:\n${report}`); + } + + rmSync(join(testRoot, '.env.local')); + rmSync(join(testRoot, 'untracked-secret.txt')); + const clean = spawnSync(process.execPath, ['tools/verify-git-safety.mjs'], { cwd: testRoot, encoding: 'utf8' }); + if (clean.status !== 0) throw new Error(`Gate lehnt sauberen Arbeitsbaum ab:\n${clean.stdout}\n${clean.stderr}`); + + console.log('Git-Sicherheitsgate-Regressionstest erfolgreich: unversionierte Secrets werden blockiert, sauberer Arbeitsbaum wird akzeptiert.'); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety.mjs new file mode 100644 index 0000000..840a10a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-safety.mjs @@ -0,0 +1,103 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { extname, join, relative, sep, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const failures = []; +const warnings = []; +const normalize = value => value.split(sep).join('/').replace(/^\.\//, ''); + +const forbiddenPathPatterns = [ + /(^|\/)node_modules(\/|$)/i, + /(^|\/)runtime(\/|$)/i, + /(^|\/)docker-data(\/|$)/i, + /(^|\/)release-output(\/|$)/i, + /(^|\/)operations\/(staging|updates|rollback|apply-[^/]+)(\/|$)/i, + /(^|\/)operations\/(pending-operation|last-operation|first-admin-code)\.json$/i, + /(^|\/)operations\/first-admin-code\.txt$/i, + /(^|\/)(uploads|backups|logs|sessions|tmp|temp)(\/|$)/i, + /(^|\/)updates(\/|$)/i, + /(^|\/)db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i, + /(^|\/)local-ai\/(comfyui|downloads|extract-temp)(\/|$)/i, + /(^|\/)\.vendoo-install-mode$/i, + /(^|\/)\.vendoo-github-state\.json$/i, +]; + +const allowedEnv = new Set(['.env.example', '.env.docker.example']); +const binaryExt = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.zip', '.pdf', '.woff', '.woff2']); +const secretPatterns = [ + ['OpenAI-/kompatibler Schlüssel', /\bsk-[A-Za-z0-9_-]{20,}\b/g], + ['GitHub Classic Token', /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g], + ['GitHub Fine-grained Token', /\bgithub_pat_[A-Za-z0-9_]{30,}\b/g], + ['Slack Token', /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g], + ['Private Key', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g], + ['AWS Access Key', /\bAKIA[0-9A-Z]{16}\b/g], +]; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === '.git' || name === 'node_modules' || name === 'release-output') continue; + const abs = join(dir, name); + const rel = normalize(relative(root, abs)); + const stat = lstatSync(abs); + if (stat.isSymbolicLink()) { + warnings.push(`Symlink wird nicht geprüft oder gepackt: ${rel}`); + continue; + } + if (stat.isDirectory()) walk(abs, out); + else out.push(rel); + } + return out; +} + +// Absichtlich immer den kompletten Arbeitsbaum prüfen. Ein Git-Erstimport enthält +// viele noch unversionierte Dateien; "git ls-files" würde diese nicht erfassen. +const files = walk(root); +for (const rel of files) { + const envLike = /(^|\/)\.env(?:\..+)?$/i.test(rel); + if (envLike && !allowedEnv.has(rel)) failures.push(`Verbotene Environment-Datei: ${rel}`); + if (!allowedEnv.has(rel) && forbiddenPathPatterns.some(pattern => pattern.test(rel))) { + failures.push(`Verbotene Laufzeit-/Secret-Datei: ${rel}`); + } + + const abs = join(root, rel); + if (!existsSync(abs)) continue; + const stat = lstatSync(abs); + if (stat.size > 50 * 1024 * 1024) failures.push(`Datei größer als 50 MiB: ${rel}`); + if (binaryExt.has(extname(rel).toLowerCase()) || stat.size > 5 * 1024 * 1024) continue; + + let text; + try { text = readFileSync(abs, 'utf8'); } catch { continue; } + for (const [label, pattern] of secretPatterns) { + pattern.lastIndex = 0; + if (pattern.test(text)) failures.push(`${label} in ${rel}`); + } + + if (!allowedEnv.has(rel)) { + const envAssignment = /^(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY|ETSY_API_KEY|EBAY_CLIENT_SECRET|SMTP_PASS|VENDOO_SETUP_TOKEN|LOCAL_IMAGE_TOKEN)=(.+)$/gm; + for (const match of text.matchAll(envAssignment)) { + const value = String(match[1] || '').trim(); + if (value && !/^\$\{|^<|^CHANGEME$/i.test(value)) failures.push(`Befülltes Secret-Feld in ${rel}: ${match[0].split('=')[0]}`); + } + } +} + +const gitignorePath = join(root, '.gitignore'); +if (!existsSync(gitignorePath)) { + failures.push('.gitignore fehlt.'); +} else { + const gitignore = readFileSync(gitignorePath, 'utf8'); + for (const marker of ['.env', 'node_modules/', 'runtime/', 'operations/pending-operation.json', 'local-ai/comfyui/', '.vendoo-install-mode']) { + if (!gitignore.includes(marker)) failures.push(`.gitignore enthält nicht: ${marker}`); + } +} + +const uniqueFailures = [...new Set(failures)]; +if (uniqueFailures.length) { + console.error(`Git-Sicherheitsgate fehlgeschlagen (${uniqueFailures.length}):`); + uniqueFailures.slice(0, 100).forEach(item => console.error(`- ${item}`)); + if (uniqueFailures.length > 100) console.error(`- ... ${uniqueFailures.length - 100} weitere Treffer`); + process.exit(1); +} +console.log(`Git-Sicherheitsgate erfolgreich: ${files.length} Dateien geprüft; keine Secrets oder Laufzeitdaten gefunden.`); +warnings.forEach(item => console.warn(`[WARN] ${item}`)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-staging.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-staging.mjs new file mode 100644 index 0000000..bc83b43 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-git-staging.mjs @@ -0,0 +1,49 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { prepareGitStaging } from './prepare-git-staging.mjs'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-git-staging-')); +const source = join(temp, 'source'); +const destination = join(temp, 'repo'); +const write = (rel, value = rel) => { + const target = join(source, rel); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, value, 'utf8'); +}; + +try { + mkdirSync(source, { recursive: true }); + mkdirSync(join(destination, '.git'), { recursive: true }); + writeFileSync(join(destination, '.git', 'marker'), 'keep', 'utf8'); + writeFileSync(join(destination, 'old-file.txt'), 'remove', 'utf8'); + + write('package.json', '{"name":"vendoo"}'); + write('.env.example', 'OPENAI_API_KEY='); + write('.env.docker.example', 'VENDOO_SETUP_TOKEN='); + write('.env.local', 'OPENAI_API_KEY=local-secret-should-never-copy'); + write('operations/staging/job.json', '{}'); + write('updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1', 'Write-Host local-backup'); + write('updates/backups/slice22_1_20260707-194953/setup.bat', '@echo local-backup'); + write('updates/backups/slice22_1_20260707-194953/setup.ps1', 'Write-Host local-backup'); + write('logs/server.log', 'secret'); + write('uploads/image.jpg', 'binary'); + write('db/vendoo.db', 'database'); + write('local-ai/comfyui/models/model.safetensors', 'model'); + write('docs/readme.md', '# safe'); + + const result = prepareGitStaging(source, destination); + const required = ['package.json', '.env.example', '.env.docker.example', 'docs/readme.md', '.git/marker']; + const forbidden = ['old-file.txt', '.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1', 'updates/backups/slice22_1_20260707-194953/setup.bat', 'updates/backups/slice22_1_20260707-194953/setup.ps1', 'logs/server.log', 'uploads/image.jpg', 'db/vendoo.db', 'local-ai/comfyui/models/model.safetensors']; + for (const rel of required) { + if (!existsSync(join(destination, rel))) throw new Error(`Pflichtdatei fehlt im Staging: ${rel}`); + } + for (const rel of forbidden) { + if (existsSync(join(destination, rel))) throw new Error(`Verbotene Datei wurde kopiert: ${rel}`); + } + if (readFileSync(join(destination, '.git', 'marker'), 'utf8') !== 'keep') throw new Error('.git wurde verändert.'); + if (result.copied < 4 || result.skipped < 7) throw new Error('Staging-Zähler sind unplausibel.'); + console.log(`Git-Staging-Regressionstest erfolgreich: ${result.copied} sichere Dateien kopiert, ${result.skipped} lokale Einträge ausgeschlossen.`); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-hotfix-1.23.2.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-hotfix-1.23.2.mjs new file mode 100644 index 0000000..ec60302 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-hotfix-1.23.2.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const editor = read('lib/image-editor.mjs'); +const setup = read('setup.ps1'); + +assert(index.includes('name="vendoo-build" content="1.23.2"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.23.2'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.23.2'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.23.2'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.23.2'), 'Windows-Start-Cache-Buster fehlt.'); + +const promptIndex = index.indexOf('

                          Prompt & Auftrag

                          '); +const runtimeIndex = index.indexOf('

                          System & ComfyUI

                          '); +const previewIndex = index.indexOf('

                          Auswahl & Varianten

                          '); +assert(promptIndex >= 0 && runtimeIndex > promptIndex && previewIndex > runtimeIndex, 'FLUX-Kartenreihenfolge ist nicht Prompt → System → Vorschau.'); +assert(index.includes('class="flux-studio-left-stack"'), 'Linker FLUX-Stack fehlt.'); +assert(index.includes('id="flux-job-center-toggle"'), 'Job-Center-Gesamttoggle fehlt.'); +assert(index.includes('id="flux-job-collapse-all"'), 'Alle-einklappen-Schalter fehlt.'); +assert(app.includes('data-flux-job-toggle'), 'Einzelne Job-Toggles fehlen.'); +assert(style.includes('.flux-job-card.is-collapsed .flux-job-card-body'), 'Job-Collapse-Styling fehlt.'); + +assert(index.includes('id="pe-remove-background"'), 'Hintergrundentfernung fehlt im Editor.'); +assert(index.includes('id="pe-watermark-enabled"'), 'Wasserzeichen fehlt im Editor.'); +assert(index.includes('id="pe-watermark-text"'), 'Wasserzeichentext fehlt.'); +assert(!index.includes('id="remove-bg-btn"'), 'Alter Hintergrund-Button ist noch vorhanden.'); +assert(!index.includes('id="watermark-btn"'), 'Alter Wasserzeichen-Button ist noch vorhanden.'); +assert(!app.includes("getElementById('remove-bg-btn')"), 'Alter Hintergrund-Handler ist noch vorhanden.'); +assert(!app.includes("getElementById('watermark-btn')"), 'Alter Wasserzeichen-Handler ist noch vorhanden.'); +assert(editor.includes('removeUniformBackground'), 'Serverseitige Hintergrundentfernung fehlt.'); +assert(editor.includes('createWatermarkSvg'), 'Serverseitiges Wasserzeichen fehlt.'); +assert(app.includes('applyPreviewBackgroundRemoval'), 'Hintergrundvorschau fehlt.'); +assert(app.includes('applyPreviewWatermark'), 'Wasserzeichenvorschau fehlt.'); + +const extensionFiles = [ + 'extension/content.js', + 'extensions/content.js', + 'extensions/chrome/content.js', + 'extensions/edge/content.js', + 'extensions/firefox/content.js', +]; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Titelfeldschutz fehlt.`); + assert(content.includes('if (!trigger || isLikelyTitleField(trigger)) return false;'), `${file}: Choice-Felder schützen Titel nicht.`); +} + +console.log('Vendoo 1.23.2 Hotfix-Verifikation erfolgreich.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-installer-shell.sh b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-installer-shell.sh new file mode 100644 index 0000000..a0ba38a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-installer-shell.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP_DIR=$(mktemp -d) +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT INT TERM + +mkdir -p "$TMP_DIR/bin" +cat > "$TMP_DIR/bin/docker" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "docker $*" >> "${FAKE_DOCKER_LOG:?}" +exit 0 +EOF +cat > "$TMP_DIR/bin/curl" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF +chmod +x "$TMP_DIR/bin/docker" "$TMP_DIR/bin/curl" + +run_mode() { + name=$1 + input=$2 + dir="$TMP_DIR/$name" + mkdir -p "$dir" + tar -C "$ROOT_DIR" --exclude=node_modules --exclude=release-output -cf - . | tar -C "$dir" -xf - + FAKE_DOCKER_LOG="$dir/docker.log" + export FAKE_DOCKER_LOG + printf '%b' "$input" | PATH="$TMP_DIR/bin:$PATH" sh "$dir/setup.sh" install > "$dir/out.log" 2>&1 + + grep -q 'keine Datei musste manuell bearbeitet werden' "$dir/out.log" + grep -q '^VENDOO_SETUP_TOKEN=[0-9a-f]\{64\}$' "$dir/.env" + grep -q 'docker compose build --pull' "$dir/docker.log" + grep -q 'docker compose up -d --remove-orphans' "$dir/docker.log" + test "$(stat -c '%a' "$dir/.env")" = 600 +} + +run_mode docker '1\n8124\nnein\n' +grep -q '^VENDOO_PUBLISH_ADDRESS=127.0.0.1$' "$TMP_DIR/docker/.env" +grep -q '^VENDOO_BIND_HOST=0.0.0.0$' "$TMP_DIR/docker/.env" + +run_mode nas '2\n9124\nhttp://192.168.1.50:9124\nnein\n' +test -d "$TMP_DIR/nas/runtime/config" +test -d "$TMP_DIR/nas/runtime/db" +grep -q '^VENDOO_PUBLISH_ADDRESS=0.0.0.0$' "$TMP_DIR/nas/.env" +grep -q '^PUBLIC_BASE_URL=http://192.168.1.50:9124$' "$TMP_DIR/nas/.env" + +run_mode vps '3\n8124\nvendoo.example.invalid\nnein\n' +grep -q '^VENDOO_DOMAIN=vendoo.example.invalid$' "$TMP_DIR/vps/.env" +grep -q '^PUBLIC_BASE_URL=https://vendoo.example.invalid$' "$TMP_DIR/vps/.env" +grep -q '^VENDOO_COOKIE_SECURE=true$' "$TMP_DIR/vps/.env" +grep -q '^VENDOO_TRUST_PROXY=true$' "$TMP_DIR/vps/.env" + +printf '%s\n' 'Shell-Installer-Gate erfolgreich: Docker-, NAS- und VPS-Dialog erzeugen valide, geschützte Konfiguration ohne manuelle Dateibearbeitung.' diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.35.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.35.0.mjs new file mode 100644 index 0000000..315ab3a --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.35.0.mjs @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'child_process'; +import { createPlatformKernel } from '../app/kernel/platform-kernel.mjs'; +import { ModuleRegistry } from '../app/kernel/module-registry.mjs'; +import { PolicyEngine } from '../app/kernel/policy-engine.mjs'; +import { RouteRegistry } from '../app/kernel/route-registry.mjs'; +import { EventBus } from '../app/kernel/event-bus.mjs'; +import { validateThemeDefinition } from '../app/core/themes/theme-contract.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = async (fn, code, message) => { + try { await fn(); failures.push(message); } + catch (error) { if (code && error.code !== code) failures.push(`${message} (erhalten: ${error.code || error.message})`); } +}; + +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +assert(packageJson.version === '1.35.0', `package.json ist ${packageJson.version}`); + +const kernel = createPlatformKernel({ + version: packageJson.version, + hasPermission: (role, permission) => role === 'admin' && permission === 'security.manage', +}); +await kernel.start(); +const snapshot = kernel.snapshot(); +assert(snapshot.architecture === 'modular-monolith', 'Architekturtyp fehlt'); +assert(snapshot.modules.length === 12, `Erwartet 12 Module, erhalten ${snapshot.modules.length}`); +assert(snapshot.modules.every(module => module.state === 'running'), 'Nicht alle Module laufen'); +assert(snapshot.modules.every(module => module.status === 'legacy-bridge'), 'Bestandsmodule müssen als legacy-bridge markiert sein'); +assert(snapshot.features.some(flag => flag.name === 'platform.module-kernel' && flag.value), 'Kernel-Feature-Flag fehlt'); +assert(snapshot.policies.some(policy => policy.id === 'platform.security-admin'), 'Security-Admin-Policy fehlt'); +const health = await kernel.health(); +assert(health.ok, 'Kernel-Healthcheck ist nicht erfolgreich'); +await kernel.stop(); +assert(kernel.snapshot().lifecycle.state === 'stopped', 'Kernel stoppt nicht sauber'); + +const registry = new ModuleRegistry(); +registry.register({ manifest: { + schemaVersion: 1, id: 'vendoo.test-a', name: 'A', version: '1.0.0', type: 'feature', status: 'native', + requires: ['vendoo.test-b'], permissions: [], provides: ['test.a'], consumes: [], owns: ['test.a'], +} }); +registry.register({ manifest: { + schemaVersion: 1, id: 'vendoo.test-b', name: 'B', version: '1.0.0', type: 'feature', status: 'native', + requires: ['vendoo.test-a'], permissions: [], provides: ['test.b'], consumes: [], owns: ['test.b'], +} }); +await expectThrow(() => registry.startAll({}), 'MODULE_DEPENDENCY_CYCLE', 'Zyklische Modulabhängigkeit wurde nicht blockiert'); + +const ownershipRegistry = new ModuleRegistry(); +ownershipRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.owner-a', name: 'Owner A', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['owner.a'], consumes: [], owns: ['shared.resource'] } }); +await expectThrow(() => ownershipRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.owner-b', name: 'Owner B', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['owner.b'], consumes: [], owns: ['shared.resource'] } }), 'MODULE_OWNERSHIP_CONFLICT', 'Doppelte Modul-Ownership wurde nicht blockiert'); + +const capabilityRegistry = new ModuleRegistry(); +capabilityRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.consumer', name: 'Consumer', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['consumer.ready'], consumes: ['missing.capability'], owns: ['consumer.state'] } }); +await expectThrow(() => capabilityRegistry.startAll({}), 'MODULE_CAPABILITY_MISSING', 'Fehlende Capability wurde nicht blockiert'); + +const policies = new PolicyEngine(); +policies.register('test.admin', ({ user }) => user?.role === 'admin'); +const routes = new RouteRegistry({ policyEngine: policies }); +const fakeApp = { get(path, handler) { this.path = path; this.handler = handler; } }; +await expectThrow(() => routes.register(fakeApp, { id: 'test.no-policy', method: 'GET', path: '/x', owner: 'vendoo.test', handler() {} }), 'ROUTE_POLICY_REQUIRED', 'Route ohne Policy wurde akzeptiert'); +routes.register(fakeApp, { id: 'test.route.read', method: 'GET', path: '/x', owner: 'vendoo.test', policy: 'test.admin', csrf: false, handler(_req, res) { res.json({ ok: true }); } }); +let deniedStatus = null; +await fakeApp.handler({ user: { role: 'viewer' }, requestId: 'test' }, { headersSent: false, status(code) { deniedStatus = code; return this; }, json() {} }, () => {}); +assert(deniedStatus === 403, `Deny-by-default-Route gab ${deniedStatus} statt 403 zurück`); + +const events = new EventBus(); +let eventCalls = 0; +events.on('test.created', () => { eventCalls += 1; }, { owner: 'vendoo.test' }); +await events.emit('test.created'); +assert(eventCalls === 1, 'Event Bus liefert Event nicht aus'); +assert(events.removeOwner('vendoo.test') === 1, 'Event-Owner-Cleanup funktioniert nicht'); + +const validTheme = validateThemeDefinition({ id: 'markus-theme', name: 'Markus Theme', values: { 'color.brand': '#123456', 'radius.md': '14px' } }); +assert(validTheme.values['color.brand'] === '#123456', 'Gültiges Theme wurde nicht akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: { 'shadow.float': 'url(javascript:alert(1))' } }), 'THEME_TOKEN_FORBIDDEN', 'Nicht anpassbarer Token wurde akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: { 'color.brand': 'url(javascript:alert(1))' } }), 'THEME_VALUE_INVALID', 'Unsicherer Farbwert wurde akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe-rgb', name: 'Unsafe RGB', values: { 'color.brand': 'rgb(999,0,0)' } }), 'THEME_VALUE_INVALID', 'Farbkanal außerhalb 0-255 wurde akzeptiert'); + +const tokenCssBefore = readFileSync(join(root, 'public', 'design-system', 'tokens.css'), 'utf8'); +const generated = spawnSync(process.execPath, [join(root, 'tools', 'generate-design-tokens.mjs')], { cwd: root, encoding: 'utf8' }); +assert(generated.status === 0, `Token-Generator fehlgeschlagen: ${generated.stderr || generated.stdout}`); +const tokenCssAfter = readFileSync(join(root, 'public', 'design-system', 'tokens.css'), 'utf8'); +assert(tokenCssBefore === tokenCssAfter, 'Token-Generator ist nicht deterministisch'); +assert(tokenCssAfter.includes('[data-theme="contrast"]'), 'Kontrast-Theme fehlt'); + +const style = readFileSync(join(root, 'public', 'style.css'), 'utf8'); +assert(style.startsWith("@import url('./design-system/tokens.css?v=1.35.0');"), 'style.css bindet Tokenvertrag nicht zuerst ein'); +assert(!style.includes('Vendoo UI Foundation 2026'), 'Alte eingebettete Tokenquelle ist noch vorhanden'); +const frontendRegistry = readFileSync(join(root, 'public', 'core', 'frontend-module-registry.js'), 'utf8'); +const safeDom = readFileSync(join(root, 'public', 'core', 'safe-dom.js'), 'utf8'); +const apiClient = readFileSync(join(root, 'public', 'core', 'api-client.js'), 'utf8'); +assert(frontendRegistry.includes('AbortController') && frontendRegistry.includes('async unmount()'), 'Frontend-Modul-Lifecycle fehlt'); +assert(safeDom.includes('textContent') && !safeDom.includes('innerHTML'), 'Safe-DOM-Vertrag ist unsicher'); +assert(apiClient.includes("credentials: 'same-origin'") && apiClient.includes('X-CSRF-Token'), 'API-Client-Sicherheitsvertrag fehlt'); + +const html = readFileSync(join(root, 'public', 'index.html'), 'utf8'); +assert(html.includes('design-system/theme-runtime.js?v=1.35.0'), 'Theme Runtime ist nicht eingebunden'); +const server = readFileSync(join(root, 'server.mjs'), 'utf8'); +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) { + assert(server.includes(marker), `Serverintegration fehlt: ${marker}`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-architecture-')); +try { + writeFileSync(join(temp, 'proof.txt'), 'ok'); + assert(readFileSync(join(temp, 'proof.txt'), 'utf8') === 'ok', 'Temporäre Testumgebung ist nicht schreibbar'); +} finally { rmSync(temp, { recursive: true, force: true }); } + +if (failures.length) { + console.error(`Platform-Architecture-Gate 1.35.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(failure => console.error(`- ${failure}`)); + process.exit(1); +} +console.log(`Platform-Architecture-Gate 1.35.0 erfolgreich: ${snapshot.modules.length} Module, ${snapshot.features.length} Feature Flags, ${snapshot.policies.length} Policies, Tokenvertrag und Deny-by-default-Routen geprüft.`); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.36.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.36.0.mjs new file mode 100644 index 0000000..7362592 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-platform-architecture-1.36.0.mjs @@ -0,0 +1,46 @@ +import { readFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { ModuleRegistry } from '../app/kernel/module-registry.mjs'; +import { PolicyEngine } from '../app/kernel/policy-engine.mjs'; +import { RouteRegistry } from '../app/kernel/route-registry.mjs'; +import { EventBus } from '../app/kernel/event-bus.mjs'; +import { loadBuiltInModules } from '../app/modules/catalog.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = async (fn, code, message) => { try { await fn(); failures.push(message); } catch (error) { if (error.code !== code) failures.push(`${message} (${error.code || error.message})`); } }; +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +assert(pkg.version === '1.36.0', `package.json ist ${pkg.version}`); + +const modules = loadBuiltInModules(); +assert(modules.length === 12, `Erwartet 12 Module, erhalten ${modules.length}`); +const themeModule = modules.find(item => item.manifest.id === 'vendoo.themes'); +assert(themeModule?.manifest.status === 'native', 'Theme-Modul ist nicht nativ'); +assert(modules.filter(item => item.manifest.status === 'legacy-bridge').length === 11, 'Es müssen 11 Legacy-Bridges verbleiben'); +const registry = new ModuleRegistry(); +for (const module of modules) registry.register(module); +assert(registry.snapshot().length === 12, 'Modulregistrierung unvollständig'); + +const cycle = new ModuleRegistry(); +cycle.register({ manifest: { schemaVersion:1,id:'vendoo.a',name:'A',version:'1.0.0',type:'feature',status:'native',requires:['vendoo.b'],permissions:[],provides:['a.ready'],consumes:[],owns:['a.state'] } }); +cycle.register({ manifest: { schemaVersion:1,id:'vendoo.b',name:'B',version:'1.0.0',type:'feature',status:'native',requires:['vendoo.a'],permissions:[],provides:['b.ready'],consumes:[],owns:['b.state'] } }); +await expectThrow(() => cycle.startAll({}), 'MODULE_DEPENDENCY_CYCLE', 'Zyklus wurde nicht blockiert'); + +const policies = new PolicyEngine(); +policies.register('themes.use', ({ user }) => Boolean(user)); +const routes = new RouteRegistry({ policyEngine: policies }); +const fake = { get(_path, handler) { this.handler = handler; } }; +await expectThrow(() => routes.register(fake, { id:'themes.no-policy',method:'GET',path:'/x',owner:'vendoo.themes',handler(){} }), 'ROUTE_POLICY_REQUIRED', 'Route ohne Policy wurde akzeptiert'); +routes.register(fake, { id:'themes.read',method:'GET',path:'/x',owner:'vendoo.themes',policy:'themes.use',csrf:false,handler(_req,res){res.json({ok:true});} }); +let status = 0; await fake.handler({ user:null, requestId:'test' }, { headersSent:false,status(code){status=code;return this;},json(){} },()=>{}); +assert(status === 403, `Deny-by-default ergab ${status}`); + +const events = new EventBus(); let count = 0; events.on('themes.changed', () => count++, { owner:'vendoo.themes' }); await events.emit('themes.changed'); +assert(count === 1 && events.removeOwner('vendoo.themes') === 1, 'Event-Bus Owner-Cleanup fehlerhaft'); +const kernelSource = readFileSync(join(root, 'app/kernel/platform-kernel.mjs'), 'utf8'); +for (const marker of ["policies.register('themes.use'", "policies.register('themes.manage'", "features.define('themes.manager'", "features.define('themes.accessibility-gate'"]) assert(kernelSource.includes(marker), `Kernelvertrag fehlt: ${marker}`); + +if (failures.length) { console.error(`Platform-Architecture-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); failures.forEach(item => console.error(`- ${item}`)); process.exit(1); } +console.log('Platform-Architecture-Gate 1.36.0 erfolgreich: 12 Module, 1 natives Theme-Modul, 11 Legacy-Bridges, Policies, Routenverträge und Event-Grenzen geprüft.'); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.0.mjs new file mode 100644 index 0000000..7fa0d90 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.0.mjs @@ -0,0 +1,105 @@ +import { readFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import sharp from 'sharp'; +import { renderImageVersion, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const jobs = read('lib/ai-flux-prompt-jobs.mjs'); +const router = read('lib/ai-router.mjs'); +const setup = read('setup.ps1'); +const packageJson = JSON.parse(read('package.json')); + +assert(packageJson.version === '1.25.0', 'Paketversion ist nicht 1.25.0.'); +assert(index.includes('name="vendoo-build" content="1.25.0"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.0'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.0'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.0'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.0'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('container-name:listing-editor'), 'Inhaltsbreitenabhängiger Listing-Editor fehlt.'); +assert(style.includes('@container listing-editor (max-width: 760px)'), 'Listing-Editor-Container-Breakpoint fehlt.'); +assert(style.includes('.detail-description-pane .ql-toolbar.ql-snow'), 'Responsive WYSIWYG-Toolbar fehlt.'); +assert(style.includes('Broad width hardening for all primary workspaces'), 'Globale Breitenhärtung fehlt.'); + +assert(index.includes('id="smart-copy-modal"'), 'Smart-Copy-Modal fehlt.'); +assert(app.includes('function openSmartCopyModal'), 'Smart-Copy-Modalsteuerung fehlt.'); +assert(app.includes('renderSmartCopyLauncher'), 'Smart-Copy-Einstieg fehlt.'); +assert(!style.includes('.vd-button.is-loading { animation: vd-publish-spin'), 'Gesamter Publish-Button rotiert weiterhin.'); +assert(app.includes("button.textContent = index === 0 ? 'Wird geladen …' : '…'"), 'Ruhiger Publish-Ladestatus fehlt.'); + +assert(index.includes('id="flux-prompt-lab"'), 'FLUX Prompt Lab fehlt.'); +assert(index.includes('id="flux-prompt-lab-ai"'), 'AI-Prompt-Aktion fehlt.'); +assert(server.includes("app.post('/api/flux-prompt/improve'"), 'Prompt-Verbesserungs-API fehlt.'); +assert(router.includes('export async function generateText'), 'Provider-Routing für Text fehlt.'); +for (const file of ['lib/ai-claude.mjs','lib/ai-openai.mjs','lib/ai-openrouter.mjs','lib/ai-local.mjs']) { + assert(read(file).includes('export async function generateText'), `${file}: Textgenerierung fehlt.`); +} + +assert(index.includes('id="flux-history-page-size"'), 'FLUX-Bildpagination fehlt.'); +assert(index.includes('id="flux-history-pagination"') || index.includes('class="flux-history-pagination"'), 'FLUX-Bild-Paginierungsleiste fehlt.'); +assert(db.includes('export function paginateFluxImages'), 'Serverseitige FLUX-Bildpagination fehlt.'); +assert(server.includes('paginateFluxImages'), 'FLUX-Bildpagination ist nicht angebunden.'); +assert(jobs.includes('Math.min(15, Number(pageSize)'), 'Job-Center ist nicht auf maximal 15 begrenzt.'); +assert(index.includes(''), 'Job-Seitengrößen 5/10/15 fehlen.'); + +assert(style.includes('.media-card-favorite-toggle'), 'Galerie-Favoriten-Overlay fehlt.'); +assert(style.includes('.media-card-select input:checked + span::after') && app.includes(''), 'Galerie-Auswahlfeld ist nicht sauber gestaltet.'); +assert(style.includes('.media-card-actions { display:grid'), 'Galerie-Aktionsraster fehlt.'); +assert(app.includes('class="media-card-preview"'), 'Galerie-Vorschauaktion fehlt.'); + +const extensionFiles = ['extension/content.js', 'extensions/content.js', 'extensions/chrome/content.js', 'extensions/edge/content.js', 'extensions/firefox/content.js']; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Vinted-Titelfeldschutz fehlt.`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-125-bg-')); +try { + const uploadRoot = join(temp, 'uploads'); + mkdirSync(join(uploadRoot, 'verification'), { recursive: true }); + const source = join(uploadRoot, 'verification', 'source.png'); + const image = Buffer.from(''); + await sharp(image).png().toFile(source); + const render = async sensitivity => renderImageVersion({ + uploadRoot, + sourcePath: 'verification/source.png', + rootPath: 'verification/source.png', + context: 'verification', + operations: normalizeEditorOperations({ remove_background: true, background_sensitivity: sensitivity, format: 'png' }), + }); + const low = await render(5); + const high = await render(90); + const countTransparent = async filename => { + const raw = await sharp(join(uploadRoot, filename)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let count = 0; + for (let i = 3; i < raw.data.length; i += raw.info.channels) if (raw.data[i] < 32) count += 1; + return count; + }; + const lowTransparent = await countTransparent(low.filename); + const highTransparent = await countTransparent(high.filename); + assert(highTransparent > lowTransparent, 'Hintergrundintensität reagiert nicht.'); + console.log(JSON.stringify({ + ok: true, + release: '1.25.0', + listing_editor_container_responsive: true, + gallery_card_ux: true, + smart_copy_modal: true, + publish_refresh_without_button_spin: true, + flux_history_pagination: [5,10,15], + flux_job_pagination: [5,10,15], + flux_prompt_lab_providers: ['claude','openai','openrouter','local'], + background_sensitivity: { low_transparent_pixels: lowTransparent, high_transparent_pixels: highTransparent }, + vinted_title_guard_preserved: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1-static.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1-static.mjs new file mode 100644 index 0000000..26afdfa --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1-static.mjs @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const read = file => readFileSync(resolve(root, file), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const pkg = JSON.parse(read('package.json')); +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const setup = read('setup.ps1'); + +assert(pkg.version === '1.25.1', 'Paketversion ist nicht 1.25.1.'); +assert(index.includes('name="vendoo-build" content="1.25.1"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.1'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.1'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.1'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.1'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('grid-template-columns:repeat(auto-fit'), 'Automatisch fließendes Galerie-Raster fehlt.'); +assert(style.includes('container-name:media-card'), 'Galeriekarten sind keine Container.'); +assert(style.includes('@container media-card (max-width: 235px)'), 'Schmale Kartendarstellung fehlt.'); +assert(style.includes('@container media-card (min-width: 340px)'), 'Breite Kartendarstellung fehlt.'); +assert(app.includes('class="media-card-open"'), 'Klickbare Bildfläche fehlt.'); +assert(app.includes("mediaActionIcon('open')"), 'Professioneller Bildöffnen-Indikator fehlt.'); +assert(!app.includes('class="media-card-preview"'), 'Alter großer Öffnen-/Vorschau-Button ist noch vorhanden.'); +assert(app.includes('class="media-action"'), 'Adaptives Aktionsdock fehlt.'); +assert(app.includes("mediaActionIcon('edit')") && app.includes("mediaActionIcon('delete')"), 'Galerieaktionssymbole fehlen.'); +assert(index.match(/data-media-pagination/g)?.length === 2, 'Pagination muss oben und unten vorhanden sein.'); +assert(index.match(/data-media-page-jump/g)?.length === 2, 'Direkte Seitenauswahl muss oben und unten vorhanden sein.'); +assert(app.includes("root.querySelectorAll('[data-media-page-action]')"), 'Paginierungsbuttons sind nicht angebunden.'); +assert(app.includes("root.querySelectorAll('[data-media-page-jump]')"), 'Direkter Seitensprung ist nicht angebunden.'); +assert(style.includes('.media-pagination-top') && style.includes('.media-pagination-bottom'), 'Pagination ist nicht oben und unten gestaltet.'); +assert(style.includes('.media-card.is-selected .media-card-image'), 'Sichtbarer Auswahlzustand fehlt.'); +assert(style.includes('@media (hover:none)'), 'Touch-Geräte werden nicht berücksichtigt.'); + +console.log(JSON.stringify({ + ok: true, + release: '1.25.1', + adaptive_grid: true, + card_container_queries: true, + full_image_click_target: true, + oversized_open_button_removed: true, + adaptive_selection_and_favorite_overlays: true, + adaptive_action_dock: true, + pagination_top_and_bottom: true, + direct_page_jump: true, + touch_behavior: true, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1.mjs new file mode 100644 index 0000000..01caacc --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.25.1.mjs @@ -0,0 +1,115 @@ +import { readFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import sharp from 'sharp'; +import { renderImageVersion, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const jobs = read('lib/ai-flux-prompt-jobs.mjs'); +const router = read('lib/ai-router.mjs'); +const setup = read('setup.ps1'); +const packageJson = JSON.parse(read('package.json')); + +assert(packageJson.version === '1.25.1', 'Paketversion ist nicht 1.25.1.'); +assert(index.includes('name="vendoo-build" content="1.25.1"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.1'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.1'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.1'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.1'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('container-name:listing-editor'), 'Inhaltsbreitenabhängiger Listing-Editor fehlt.'); +assert(style.includes('@container listing-editor (max-width: 760px)'), 'Listing-Editor-Container-Breakpoint fehlt.'); +assert(style.includes('.detail-description-pane .ql-toolbar.ql-snow'), 'Responsive WYSIWYG-Toolbar fehlt.'); +assert(style.includes('Broad width hardening for all primary workspaces'), 'Globale Breitenhärtung fehlt.'); + +assert(index.includes('id="smart-copy-modal"'), 'Smart-Copy-Modal fehlt.'); +assert(app.includes('function openSmartCopyModal'), 'Smart-Copy-Modalsteuerung fehlt.'); +assert(app.includes('renderSmartCopyLauncher'), 'Smart-Copy-Einstieg fehlt.'); +assert(!style.includes('.vd-button.is-loading { animation: vd-publish-spin'), 'Gesamter Publish-Button rotiert weiterhin.'); +assert(app.includes("button.textContent = index === 0 ? 'Wird geladen …' : '…'"), 'Ruhiger Publish-Ladestatus fehlt.'); + +assert(index.includes('id="flux-prompt-lab"'), 'FLUX Prompt Lab fehlt.'); +assert(index.includes('id="flux-prompt-lab-ai"'), 'AI-Prompt-Aktion fehlt.'); +assert(server.includes("app.post('/api/flux-prompt/improve'"), 'Prompt-Verbesserungs-API fehlt.'); +assert(router.includes('export async function generateText'), 'Provider-Routing für Text fehlt.'); +for (const file of ['lib/ai-claude.mjs','lib/ai-openai.mjs','lib/ai-openrouter.mjs','lib/ai-local.mjs']) { + assert(read(file).includes('export async function generateText'), `${file}: Textgenerierung fehlt.`); +} + +assert(index.includes('id="flux-history-page-size"'), 'FLUX-Bildpagination fehlt.'); +assert(index.includes('id="flux-history-pagination"') || index.includes('class="flux-history-pagination"'), 'FLUX-Bild-Paginierungsleiste fehlt.'); +assert(db.includes('export function paginateFluxImages'), 'Serverseitige FLUX-Bildpagination fehlt.'); +assert(server.includes('paginateFluxImages'), 'FLUX-Bildpagination ist nicht angebunden.'); +assert(jobs.includes('Math.min(15, Number(pageSize)'), 'Job-Center ist nicht auf maximal 15 begrenzt.'); +assert(index.includes(''), 'Job-Seitengrößen 5/10/15 fehlen.'); + +assert(style.includes('.media-card-favorite-toggle'), 'Galerie-Favoriten-Overlay fehlt.'); +assert(style.includes('.media-card-select input:checked + span::after') && app.includes(''), 'Galerie-Auswahlfeld ist nicht sauber gestaltet.'); +assert(style.includes('grid-template-columns:repeat(auto-fit'), 'Galerie verwendet kein automatisch fließendes Raster.'); +assert(style.includes('container-name:media-card') && style.includes('@container media-card'), 'Galeriekarten skalieren nicht anhand ihrer eigenen Breite.'); +assert(app.includes('class="media-card-open"') && app.includes('data-media-preview'), 'Klickbare Bildfläche fehlt.'); +assert(!app.includes('class="media-card-preview"'), 'Der alte große Vorschau-/Öffnen-Button ist noch vorhanden.'); +assert(app.includes('class="media-action"'), 'Adaptives Galerie-Aktionsdock fehlt.'); +assert(index.match(/data-media-pagination/g)?.length === 2, 'Pagination muss oberhalb und unterhalb der Galerie vorhanden sein.'); +assert(index.match(/data-media-page-jump/g)?.length === 2, 'Direkte Seitenauswahl fehlt oben oder unten.'); +assert(app.includes("root.querySelectorAll('[data-media-page-jump]')"), 'Seitensprung ist nicht angebunden.'); +assert(style.includes('.media-pagination-top') && style.includes('.media-pagination-bottom'), 'Obere/untere Pagination ist nicht gestaltet.'); + +const extensionFiles = ['extension/content.js', 'extensions/content.js', 'extensions/chrome/content.js', 'extensions/edge/content.js', 'extensions/firefox/content.js']; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Vinted-Titelfeldschutz fehlt.`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-125-bg-')); +try { + const uploadRoot = join(temp, 'uploads'); + mkdirSync(join(uploadRoot, 'verification'), { recursive: true }); + const source = join(uploadRoot, 'verification', 'source.png'); + const image = Buffer.from(''); + await sharp(image).png().toFile(source); + const render = async sensitivity => renderImageVersion({ + uploadRoot, + sourcePath: 'verification/source.png', + rootPath: 'verification/source.png', + context: 'verification', + operations: normalizeEditorOperations({ remove_background: true, background_sensitivity: sensitivity, format: 'png' }), + }); + const low = await render(5); + const high = await render(90); + const countTransparent = async filename => { + const raw = await sharp(join(uploadRoot, filename)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let count = 0; + for (let i = 3; i < raw.data.length; i += raw.info.channels) if (raw.data[i] < 32) count += 1; + return count; + }; + const lowTransparent = await countTransparent(low.filename); + const highTransparent = await countTransparent(high.filename); + assert(highTransparent > lowTransparent, 'Hintergrundintensität reagiert nicht.'); + console.log(JSON.stringify({ + ok: true, + release: '1.25.1', + listing_editor_container_responsive: true, + gallery_card_ux: true, + adaptive_gallery_grid: true, + full_image_click_target: true, + pagination_top_and_bottom: true, + smart_copy_modal: true, + publish_refresh_without_button_spin: true, + flux_history_pagination: [5,10,15], + flux_job_pagination: [5,10,15], + flux_prompt_lab_providers: ['claude','openai','openrouter','local'], + background_sensitivity: { low_transparent_pixels: lowTransparent, high_transparent_pixels: highTransparent }, + vinted_title_guard_preserved: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.0-static.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.0-static.mjs new file mode 100644 index 0000000..81b76d9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.0-static.mjs @@ -0,0 +1,94 @@ +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const index = read('public/index.html'); +const app = read('public/app.js'); +const css = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const fees = read('lib/fees.mjs'); +const icons = read('public/vendoo-icons.js'); +const setup = read('setup.ps1'); + +assert(pkg.version === '1.26.0', 'Paketversion ist nicht 1.26.0.'); +assert(lock.version === '1.26.0' && lock.packages?.['']?.version === '1.26.0', 'Lockfile-Version ist nicht 1.26.0.'); +assert(index.includes('name="vendoo-build" content="1.26.0"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.26.0'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('vendoo-icons.js?v=1.26.0'), 'Iconbibliothek ist nicht eingebunden.'); +assert(index.includes('app.js?v=1.26.0'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.26.0'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.26.0'), 'Windows-Start-Cache-Buster fehlt.'); + +const dashboardPos = index.indexOf('data-tab="dashboard"'); +const newArticlePos = index.indexOf('data-tab="generator"'); +const todayPos = index.indexOf('data-tab="today"'); +assert(dashboardPos >= 0 && newArticlePos > dashboardPos && newArticlePos < todayPos, 'Neuer Artikel steht nicht direkt unter Dashboard.'); +assert(index.includes('Neuer Artikel'), 'Navigationsbezeichnung Neuer Artikel fehlt.'); +assert(index.includes('Artikel'), 'Navigationsbezeichnung Artikel fehlt.'); +assert(index.includes('Artikel generieren'), 'Artikel-generieren-Bezeichnung fehlt.'); +assert(!index.includes('>Neues Listing<'), 'Alte UI-Bezeichnung Neues Listing ist noch vorhanden.'); +assert(!index.includes('>Listing generieren<'), 'Alte UI-Bezeichnung Listing generieren ist noch vorhanden.'); + +assert(index.includes('id="extensions"'), 'Extension Center fehlt.'); +assert(index.includes('id="extensions-prepare-active"'), 'Ein-Klick-Vorbereitung für aktiven Browser fehlt.'); +assert(app.includes('detectActiveBrowser'), 'Browsererkennung fehlt.'); +assert(server.includes("app.get('/api/extensions/info'"), 'Extension-Info-Route fehlt.'); +assert(server.includes("app.post('/api/extensions/prepare'"), 'Extension-Prepare-Route fehlt.'); +assert(server.includes("app.get('/api/extensions/download/:browser'"), 'Extension-Download-Route fehlt.'); +for (const browser of ['chrome', 'edge', 'firefox', 'safari']) { + assert(existsSync(join(root, 'extensions', browser)), `Extension-Ordner ${browser} fehlt.`); + assert(existsSync(join(root, 'extensions', 'packages', `vendoo-link-${browser}-1.7.0.zip`)), `Extension-Paket ${browser} fehlt.`); +} +assert(existsSync(join(root, 'extensions', 'safari', 'web-extension', 'manifest.json')), 'Safari-WebExtension-Manifest fehlt.'); +assert(existsSync(join(root, 'extensions', 'safari', 'build-safari-extension.command')), 'Safari-Konvertierungsskript fehlt.'); + +for (const id of ['media-zoom-in','media-zoom-out','media-zoom-fit','media-zoom-original','media-zoom-range','media-lightbox-stage']) { + assert(index.includes(`id="${id}"`), `Zoom-Steuerung ${id} fehlt.`); +} +assert(app.includes('function fitMediaLightbox'), 'Fit-Zoom fehlt.'); +assert(app.includes("zoomStage?.addEventListener('wheel'"), 'Mausrad-Zoom fehlt.'); +assert(app.includes("zoomStage?.addEventListener('pointerdown'"), 'Zoom-Panning fehlt.'); +assert(icons.includes("'heart-filled'"), 'Favoriten-Icon fehlt.'); +assert(icons.includes('check:'), 'Auswahl-Icon fehlt.'); + +assert(db.includes('function parseJsonArray(value)'), 'Robuster JSON-Array-Parser fehlt.'); +assert(db.includes("String(value).split(',')"), 'Fallback für ältere Artikeldaten fehlt.'); +assert(app.includes('const workspace = document.getElementById(\'publish-workspace\')'), 'Publish-Workspace-Container fehlt.'); +assert(!app.includes('scope.querySelector(\'[data-publish-direct]'), 'Alter Publish-Scope-Fehler ist noch vorhanden.'); +assert(app.includes('openSmartCopyModal(selectedPublishListing())'), 'Smart-Copy-Popup-Verknüpfung fehlt.'); +assert(app.includes('smart-copy-center-description'), 'Zentrale Smart-Copy-Datenansicht fehlt.'); + +assert(fees.includes("'ebay-de':") && fees.includes("fees: 0"), 'eBay-Privatgebühr ist nicht auf 0 gesetzt.'); +assert(fees.includes("vinted:") && fees.includes('Käuferschutz'), 'Vinted-Gebührenhinweis fehlt.'); +assert(fees.includes("'ebay-ka':") && fees.includes('normalen Privatverkauf'), 'Kleinanzeigen-Hinweis fehlt.'); +assert(fees.includes('gross * 0.065') && fees.includes('gross * 0.04 + 0.30'), 'Etsy-Kalkulation fehlt.'); +assert(index.includes('Stand 08.07.2026'), 'Gebührenstand fehlt.'); + +const ids = [...index.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; +assert(!duplicates.length, `Doppelte HTML-IDs: ${duplicates.join(', ')}`); +let depth = 0; +for (const char of css.replace(/\/\*[\s\S]*?\*\//g, '')) { + if (char === '{') depth++; + if (char === '}') depth--; + assert(depth >= 0, 'CSS enthält eine vorzeitig geschlossene Klammer.'); +} +assert(depth === 0, 'CSS-Klammern sind nicht ausgeglichen.'); + +const result = { + release: '1.26.0', + html_ids: ids.length, + extension_targets: 4, + checks: [ + 'version-and-cache-busting', 'article-navigation', 'publish-hardening', + 'smart-copy-modal', 'media-zoom', 'local-icon-library', 'fee-model', + 'extension-center', 'safari-source', 'unique-html-ids', 'balanced-css' + ] +}; +console.log(JSON.stringify(result, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.1-static.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.1-static.mjs new file mode 100644 index 0000000..fcfcee9 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.26.1-static.mjs @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} + +const files = walk(root); +for (const file of files) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +if (pkg.version !== '1.26.1') failures.push(`package.json-Version ist ${pkg.version}`); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +summary.html_ids = [...html.matchAll(/\bid="([^"]+)"/g)].length; +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); + +for (const marker of ['content="1.26.1"', 'style.css?v=1.26.1', 'app.js?v=1.26.1']) { + if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +} +if (!app.startsWith("const VENDOO_UI_BUILD = '1.26.1';")) failures.push('UI-Build ist nicht 1.26.1'); +for (const marker of ["app.get('/healthz'", "app.get('/api/admin/diagnostics'", "db.pragma('quick_check'", 'probeComfyUi']) { + if (!server.includes(marker)) failures.push(`Server-Diagnose fehlt: ${marker}`); +} +for (const path of [ + 'extensions/chrome/manifest.json', 'extensions/edge/manifest.json', + 'extensions/firefox/manifest.json', 'extensions/safari/web-extension/manifest.json', + 'local-ai/workflows/vendoo_flux_schnell_api.json', +]) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); + +const forbidden = ['FASHN', 'virtual_tryon', 'LoadImage']; +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, + version: pkg.version, + ...summary, + health_endpoint: true, + admin_diagnostics: true, + request_timeout_and_retry: true, + workspace_resize_contracts: true, + extension_variants: 4, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.27.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.27.0.mjs new file mode 100644 index 0000000..82fb737 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.27.0.mjs @@ -0,0 +1,88 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} + +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const worker = readFileSync(resolve(root, 'lib/image-batch-jobs.mjs'), 'utf8'); +const editor = readFileSync(resolve(root, 'lib/image-editor.mjs'), 'utf8'); + +if (pkg.version !== '1.27.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.27.0"', 'style.css?v=1.27.0', 'app.js?v=1.27.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.27.0';")) failures.push('UI-Build ist nicht 1.27.0'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +if (!sections.has('image-factory')) failures.push('Bildproduktion-Seite fehlt'); +if (!html.includes('data-tab="image-factory"')) failures.push('Bildproduktion-Navigation fehlt'); + +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['.image-factory-layout', '.image-factory-source-grid', '.image-factory-profile-grid', '.image-factory-job-list', '@container (max-width: 680px)']) if (!css.includes(marker)) failures.push(`Bildproduktions-CSS fehlt: ${marker}`); + +for (const marker of [ + "app.get('/api/image-batch/profiles'", "app.post('/api/image-batch/jobs'", "app.post('/api/image-batch/jobs/:id/pause'", + "app.post('/api/image-batch/jobs/:id/resume'", "app.post('/api/image-batch/jobs/:id/cancel'", "app.post('/api/image-batch/jobs/:id/retry'", + "app.get('/api/image-batch/jobs/:id/archive'", 'startImageBatchJobLoop()', +]) if (!server.includes(marker)) failures.push(`Batch-API fehlt: ${marker}`); +for (const table of ['image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items']) if (!db.includes(`CREATE TABLE IF NOT EXISTS ${table}`)) failures.push(`Datenbanktabelle fehlt: ${table}`); +for (const profile of ['ebay-main','vinted-portrait','kleinanzeigen','etsy','instagram-square','instagram-portrait','webshop','original-optimized']) if (!db.includes(`'${profile}'`)) failures.push(`Standardprofil fehlt: ${profile}`); +for (const marker of ['createImageBatchJob', 'pauseImageBatchJob', 'resumeImageBatchJob', 'cancelImageBatchJob', 'retryImageBatchJob', 'retryImageBatchItem', 'getImageBatchOutputPaths']) if (!worker.includes(`function ${marker}`) && !worker.includes(`function ${marker}(`) && !worker.includes(`export function ${marker}`)) failures.push(`Worker-Funktion fehlt: ${marker}`); +for (const marker of ['outputDirectory = null', "RESIZE_FITS = new Set(['inside', 'contain', 'cover'])", "context: 'batch'"]) if (!(editor + worker).includes(marker)) failures.push(`Render-Erweiterung fehlt: ${marker}`); +for (const marker of ['setupImageFactory()', "if (tabId === 'image-factory') loadImageFactory()", 'submitImageFactoryJob', 'media-send-to-factory']) if (!app.includes(marker) && !html.includes(marker)) failures.push(`UI-Anbindung fehlt: ${marker}`); + +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); + +for (const path of ['lib/image-batch-jobs.mjs', 'tools/verify-slice32-image-render.mjs', 'local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, version: pkg.version, ...summary, + built_in_profiles: 8, persistent_batch_jobs: true, pause_resume_cancel_retry: true, + restart_recovery: true, gallery_integration: true, zip_export: true, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.28.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.28.0.mjs new file mode 100644 index 0000000..58dd428 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.28.0.mjs @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0 }; +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const quality = readFileSync(resolve(root, 'lib/quality-center.mjs'), 'utf8'); +if (pkg.version !== '1.28.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.28.0"', 'style.css?v=1.28.0', 'app.js?v=1.28.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.28.0';")) failures.push('UI-Build ist nicht 1.28.0'); +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['data-tab="quality-center"','id="quality-center"','id="quality-ai-modal"','id="quality-analyze-visible"']) if (!html.includes(marker)) failures.push(`Quality-Center-UI fehlt: ${marker}`); +for (const marker of ['.quality-workspace','.quality-score-ring','.quality-ai-compare','@container (max-width: 920px)']) if (!css.includes(marker)) failures.push(`Quality-Center-CSS fehlt: ${marker}`); +for (const marker of ['setupQualityCenter()', "if (tabId === 'quality-center') loadQualityCenter()", 'generateQualityAiSuggestion', 'applyQualityAiSuggestion']) if (!app.includes(marker)) failures.push(`Quality-Center-JavaScript fehlt: ${marker}`); +for (const marker of ["app.get('/api/quality-center'", "app.post('/api/quality-center/:id/analyze'", "app.post('/api/quality-center/:id/ai-suggestions'", "app.post('/api/quality-center/:id/apply-suggestions'"]) if (!server.includes(marker)) failures.push(`Quality-Center-API fehlt: ${marker}`); +if (!db.includes('CREATE TABLE IF NOT EXISTS quality_reports')) failures.push('quality_reports-Tabelle fehlt'); +for (const marker of ['analyzeListingQuality','listQualityCenter','getQualityHistory','compareQualityReports','findDuplicates','inspectImages']) if (!quality.includes(marker)) failures.push(`Quality-Modul-Funktion fehlt: ${marker}`); +for (const marker of ['missing_title','missing_description','missing_photos','missing_price','duplicate_sku','photo_low_resolution']) if (!quality.includes(marker)) failures.push(`Qualitätsregel fehlt: ${marker}`); +if (!server.includes('Die AI erstellt') && !html.includes('Die AI erstellt nur Vorschläge')) failures.push('AI-Bestätigungsregel fehlt'); +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +for (const path of ['lib/quality-center.mjs','docs/SLICE_33_ARTICLE_QUALITY_CENTER.md','local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); +if (failures.length) { console.error(JSON.stringify({ ok:false, failures, summary }, null, 2)); process.exit(1); } +console.log(JSON.stringify({ ok:true, version:pkg.version, ...summary, quality_center:true, persistent_reports:true, duplicate_detection:true, image_inspection:true, ai_confirmation:true }, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.29.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.29.0.mjs new file mode 100644 index 0000000..e5d78be --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.29.0.mjs @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0, extension_packages: 0 }; +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const publishing = readFileSync(resolve(root, 'lib/publishing-jobs.mjs'), 'utf8'); +const ebay = readFileSync(resolve(root, 'lib/ebay-api.mjs'), 'utf8'); +const etsy = readFileSync(resolve(root, 'lib/etsy-api.mjs'), 'utf8'); +if (pkg.version !== '1.29.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.29.0"', 'style.css?v=1.29.0', 'app.js?v=1.29.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.29.0';")) failures.push('UI-Build ist nicht 1.29.0'); +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['data-publish-view="queue"','data-publish-view="errors"','id="publish-error-count"','Zur Publishing-Queue']) if (!html.includes(marker)) failures.push(`Publishing-Hardening-UI fehlt: ${marker}`); +for (const marker of ['loadPublishingQueueData','renderPublishingErrorsWorkspace','runPublishingJobAction','deleteSelectedPublishingJobs','publishingStatusLabel']) if (!app.includes(marker)) failures.push(`Publishing-Hardening-JavaScript fehlt: ${marker}`); +for (const marker of ["app.get('/api/publishing/jobs'", "app.post('/api/publishing/jobs'", "app.post('/api/publishing/jobs/:id/retry'", "app.post('/api/publishing/jobs/:id/action'", "app.get('/api/publishing/events'", "app.post('/api/extension/publishing/:id/complete'"]) if (!server.includes(marker)) failures.push(`Publishing-Hardening-API fehlt: ${marker}`); +for (const marker of ['CREATE TABLE IF NOT EXISTS publishing_jobs','CREATE TABLE IF NOT EXISTS publishing_events','ALTER TABLE publish_log ADD COLUMN']) if (!db.includes(marker)) failures.push(`Publishing-Datenbank fehlt: ${marker}`); +for (const marker of ['enqueuePublishingJob','DUPLICATE_PUBLISH','retry_wait','recoverPublishingJobs','claimExtensionJob','completeExtensionJob','idempotency_key']) if (!publishing.includes(marker)) failures.push(`Publishing-Modul-Vertrag fehlt: ${marker}`); +for (const marker of ['getOffer','updateOffer','withdrawOffer']) if (!ebay.includes(`function ${marker}`)) failures.push(`eBay-Hardening fehlt: ${marker}`); +if (!etsy.includes('function getEtsyListing')) failures.push('Etsy-Statusabgleich fehlt'); +for (const marker of ['5 / Seite','10 / Seite','15 / Seite','Publishing-Queue','Fehlerzentrale']) if (!app.includes(marker) && !html.includes(marker)) failures.push(`Queue-UX fehlt: ${marker}`); +for (const path of ['lib/publishing-jobs.mjs','docs/SLICE_34_PUBLISHING_HARDENING.md','local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +for (const browser of ['chrome','edge','firefox','safari']) { + const pkgPath = resolve(root, 'extensions/packages', `vendoo-link-${browser}-1.8.0.zip`); + if (!existsSync(pkgPath)) failures.push(`Extension-Paket fehlt: ${relative(root,pkgPath)}`); else summary.extension_packages += 1; +} +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); +if (failures.length) { console.error(JSON.stringify({ ok:false, failures, summary }, null, 2)); process.exit(1); } +console.log(JSON.stringify({ + ok:true, version:pkg.version, ...summary, + persistent_publishing_queue:true, + duplicate_publish_guard:true, + automatic_retry:true, + restart_recovery:true, + extension_completion:true, + ebay_sync_update_end:true, + etsy_sync:true, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.30.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.30.0.mjs new file mode 100644 index 0000000..150f263 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.30.0.mjs @@ -0,0 +1,152 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; +import { createZip } from '../lib/zip.mjs'; +import { readZip, readZipEntries } from '../lib/archive.mjs'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0, extension_packages: 0, operations_markers: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads', 'backups', 'operations'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +function expect(source, marker, label = marker) { + if (!source.includes(marker)) failures.push(`Marker fehlt: ${label}`); else summary.operations_markers += 1; +} + +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const lock = JSON.parse(readFileSync(resolve(root, 'package-lock.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const icons = readFileSync(resolve(root, 'public/vendoo-icons.js'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const operations = readFileSync(resolve(root, 'lib/operations-center.mjs'), 'utf8'); +const archive = readFileSync(resolve(root, 'lib/archive.mjs'), 'utf8'); +const setup = readFileSync(resolve(root, 'setup.ps1')); + +if (pkg.version !== '1.30.0') failures.push(`package.json-Version ist ${pkg.version}`); +if (lock.version !== '1.30.0' || lock.packages?.['']?.version !== '1.30.0') failures.push('package-lock-Version ist nicht 1.30.0'); +for (const marker of ['content="1.30.0"', 'style.css?v=1.30.0', 'app.js?v=1.30.0']) expect(html, marker, `Build-Marker ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.30.0';")) failures.push('UI-Build ist nicht 1.30.0'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); + +for (const marker of [ + 'id="operations-center-title"', 'data-operations-tab="backup"', 'data-operations-tab="updates"', + 'data-operations-tab="extensions"', 'id="operations-backup-list"', 'id="operations-update-input"', + 'id="operations-extension-clients"', 'id="operations-pending-banner"', +]) expect(html, marker, `Operations-UI ${marker}`); +for (const marker of [ + 'loadOperationsCenter', 'renderOperationsBackups', 'renderOperationsExtensions', 'operationsUploadRequest', + '/api/operations/restore/stage', '/api/operations/updates/stage', '/api/extensions/diagnostics', +]) expect(app, marker, `Operations-JavaScript ${marker}`); +for (const marker of [ + "app.get('/api/operations/status'", "app.post('/api/operations/backups'", "app.post('/api/operations/restore/stage'", + "app.post('/api/operations/updates/stage'", "app.post('/api/extensions/heartbeat'", "app.get('/api/extensions/diagnostics'", +]) expect(server, marker, `Operations-API ${marker}`); +for (const marker of [ + 'CREATE TABLE IF NOT EXISTS operation_backups', 'CREATE TABLE IF NOT EXISTS operation_settings', + 'CREATE TABLE IF NOT EXISTS update_operations', 'CREATE TABLE IF NOT EXISTS system_migrations', + 'CREATE TABLE IF NOT EXISTS extension_clients', 'operations-center-1.30.0', +]) expect(db, marker, `Operations-Datenbank ${marker}`); +for (const marker of [ + 'createDatabaseSnapshot', 'verifyBackupBuffer', 'stageRestore', 'stageUpdate', 'startAutomaticBackupLoop', + 'recordExtensionHeartbeat', 'getExtensionDiagnostics', 'VENDOO_UPDATE_MANIFEST_URL', +]) expect(operations, marker, `Operations-Modul ${marker}`); +for (const marker of ['readZipEntries', 'extractZipEntry', 'Unsicherer ZIP-Pfad']) expect(archive, marker, `ZIP-Schutz ${marker}`); +for (const marker of ['Apply-VendooPendingOperation', 'Invoke-VendooRollback', "'11' { Apply-VendooPendingOperation }", "'12' { Invoke-VendooRollback }"]) expect(setup.toString('utf8'), marker, `Setup-Operation ${marker}`); +if (!(setup[0] === 0xef && setup[1] === 0xbb && setup[2] === 0xbf)) failures.push('setup.ps1 besitzt kein UTF-8-BOM'); + +for (const icon of ['database-backup','calendar-clock','scan-check','package-check','plug-zap','file-archive','radio-tower','shield-alert']) expect(icons, `'${icon}'`, `Icon ${icon}`); + +for (const browser of ['chrome', 'edge', 'firefox', 'safari']) { + const packagePath = resolve(root, 'extensions/packages', `vendoo-link-${browser}-1.9.0.zip`); + if (!existsSync(packagePath)) failures.push(`Extension-Paket fehlt: ${relative(root, packagePath)}`); + else { + summary.extension_packages += 1; + const test = spawnSync('unzip', ['-t', packagePath], { encoding: 'utf8' }); + if (test.status !== 0) failures.push(`Extension-ZIP fehlerhaft: ${browser}`); + } +} +for (const path of [ + 'extensions/chrome/manifest.json', 'extensions/edge/manifest.json', 'extensions/firefox/manifest.json', + 'extensions/safari/web-extension/manifest.json', +]) { + const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')); + if (manifest.version !== '1.9.0') failures.push(`${path} ist nicht Version 1.9.0`); +} +for (const path of ['extensions/content.js', 'extensions/popup.js']) { + const source = readFileSync(resolve(root, path), 'utf8'); + for (const marker of ['/api/extensions/heartbeat', "version: '1.9.0'", 'vendooDiagnosticClientId']) expect(source, marker, `${path}: ${marker}`); +} + +// ZIP parser roundtrip and traversal protection. +const roundtrip = createZip([ + { name: 'backup-manifest.json', data: Buffer.from('{"ok":true}') }, + { name: 'db/vendoo.db', data: Buffer.from('sqlite-test') }, +]); +const parsed = readZip(roundtrip); +if (parsed.files.get('db/vendoo.db')?.toString() !== 'sqlite-test') failures.push('ZIP-Roundtrip fehlgeschlagen'); +let traversalBlocked = false; +try { + const malicious = createZip([{ name: '../escape.txt', data: Buffer.from('x') }]); + readZipEntries(malicious); +} catch { traversalBlocked = true; } +if (!traversalBlocked) failures.push('ZIP-Pfad-Traversal wurde nicht blockiert'); + +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, + version: pkg.version, + ...summary, + verified_sqlite_backups: true, + backup_manifest_sha256: true, + automatic_rotation: true, + restore_safety_backup: true, + update_backup_gate: true, + offline_apply: true, + rollback_foundation: true, + extension_heartbeat: true, + zip_traversal_guard: true, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.31.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.31.0.mjs new file mode 100644 index 0000000..b1d64cb --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-release-1.31.0.mjs @@ -0,0 +1,81 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, extname } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const auth = read('lib/auth.mjs'); +const security = read('lib/security.mjs'); +const db = read('lib/db.mjs'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const login = read('public/login.html'); +const css = read('public/style.css'); +const env = read('.env.example'); +const schema = read('db/schema.sql'); + +if (pkg.version !== '1.31.0') failures.push(`package.json-Version ist ${pkg.version}`); +if (lock.version !== '1.31.0' || lock.packages?.['']?.version !== '1.31.0') failures.push('package-lock-Version ist nicht 1.31.0'); +if (manifest.version !== '1.31.0') failures.push('Update-Manifest ist nicht 1.31.0'); +for (const marker of ['content="1.31.0"', 'style.css?v=1.31.0', 'app.js?v=1.31.0']) expect(html, marker, `Build-Marker ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.31.0';")) failures.push('UI-Build ist nicht 1.31.0'); +expect(server, "version: '1.31.0'", 'Health-Version 1.31.0'); +expect(server, "VENDOO_BIND_HOST || '127.0.0.1'", 'localhost als Standard-Bind-Adresse'); +expect(server, 'apiPermissionGuard', 'serverseitige Berechtigungsprüfung'); +expect(server, 'apiRateLimit', 'API-Rate-Limit'); +expect(server, "requirePermission('settings.manage')", 'OAuth-Einstellungen geschützt'); +expect(server, 'X-Vendoo-Request-ID', 'Request-ID'); +expect(server, 'https://cdn.jsdelivr.net', 'CSP-Freigabe für vorhandene UI-CDN-Assets'); +for (const role of ['admin', 'manager', 'editor', 'publisher', 'viewer']) expect(security, `${role}: {`, `Rolle ${role}`); +for (const permission of ['listings.edit', 'media.delete', 'publishing.manage', 'operations.manage', 'security.manage']) expect(security, `'${permission}'`, `Berechtigung ${permission}`); +for (const marker of ['CREATE TABLE IF NOT EXISTS resource_locks', 'acquireResourceLock', 'renewResourceLock', 'releaseResourceLock', 'multiuser-security-1.31.0']) expect(security, marker); +for (const marker of ['failed_login_count', 'locked_until', 'password_changed_at', 'last_seen_at', 'revoked_at', 'validatePasswordStrength', 'isUserLocked']) expect(auth, marker); +for (const marker of ['created_by', 'updated_by']) { expect(db, marker); expect(schema, marker); } +for (const marker of ['admin-roles-panel', 'admin-security-panel', 'admin-audit-panel']) expect(html, marker); +for (const marker of ['loadRoleMatrix', 'loadSecurityCenter', 'acquireListingLock', 'releaseListingLock', 'applyPermissionUi']) expect(app, marker); +expect(login, 'mindestens 10 Zeichen', 'starke Passwortmeldung'); +for (const marker of ['VENDOO_BIND_HOST=', 'VENDOO_ALLOWED_HOSTS=', 'VENDOO_ALLOWED_ORIGINS=', 'VENDOO_SESSION_IDLE_MINUTES=', 'VENDOO_MUTATION_RATE_LIMIT=']) expect(env, marker); +for (const marker of ['role-cards', 'listing-lock-banner', 'security-grid', 'audit-toolbar']) expect(css, marker); + +// No active VTO/FASHN implementation and free FLUX workflow remains prompt-only. +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +for (const rel of ['server.mjs', 'public/app.js', 'public/index.html']) { + const text = read(rel).toLowerCase(); + if (text.includes('fashn') || text.includes('virtual try-on') || text.includes('virtual try on')) failures.push(`${rel} enthält aktive VTO/FASHN-Referenz`); +} + +// Basic duplicate HTML id check. +const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]); +const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${[...new Set(duplicateIds)].join(', ')}`); + +// CSS brace balance ignoring strings/comments sufficiently for release gate. +const strippedCss = css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, ''); +let balance = 0; +for (const char of strippedCss) { if (char === '{') balance++; else if (char === '}') balance--; if (balance < 0) break; } +if (balance !== 0) failures.push(`CSS-Klammerbilanz ist ${balance}`); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child); + } + return out; +} +const forbidden = walk(root).filter(rel => /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) && rel !== '.env.example'); +if (forbidden.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbidden.slice(0, 8).join(', ')}`); + +if (failures.length) { + console.error(`Release-Gate 1.31.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log(`Release-Gate 1.31.0 erfolgreich: ${ids.length} eindeutige HTML-IDs, Rollen/RBAC, Sitzungen, Locks, Audit und Server-Härtung geprüft.`); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-security-1.31.0.py b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-security-1.31.0.py new file mode 100644 index 0000000..a8d9873 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-security-1.31.0.py @@ -0,0 +1,48 @@ +from pathlib import Path +import sqlite3, tempfile + +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory() as temp: + db_path = Path(temp) / 'vendoo.db' + con = sqlite3.connect(db_path) + con.execute("CREATE TABLE listings (id INTEGER PRIMARY KEY, title TEXT, platform TEXT, ai_provider TEXT)") + con.execute("INSERT INTO listings VALUES (1,'Bestehender Artikel','vinted','openrouter')") + for col, typ in [('created_by','INTEGER'),('updated_by','INTEGER')]: + try: con.execute(f'ALTER TABLE listings ADD COLUMN {col} {typ}') + except sqlite3.OperationalError: pass + con.executescript(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL DEFAULT '', + password_hash TEXT, role TEXT NOT NULL DEFAULT 'editor', active INTEGER NOT NULL DEFAULT 1, + failed_login_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT, password_changed_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, last_seen_at TEXT, revoked_at TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, user_email TEXT, action TEXT NOT NULL, + target_type TEXT, target_id TEXT, details TEXT, ip TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, acquired_at TEXT DEFAULT (datetime('now')), + renewed_at TEXT DEFAULT (datetime('now')), expires_at TEXT NOT NULL, client_label TEXT, + UNIQUE(resource_type, resource_id) + ); + ''') + con.execute("INSERT INTO users(email,name,role) VALUES ('admin@example.test','Admin','admin')") + uid = con.execute("SELECT id FROM users").fetchone()[0] + con.execute("UPDATE listings SET created_by=?, updated_by=? WHERE id=1", (uid, uid)) + con.execute("INSERT INTO resource_locks(resource_type,resource_id,user_id,token_hash,expires_at) VALUES ('listing','1',?,'hash',datetime('now','+2 minutes'))", (uid,)) + con.commit() + assert con.execute("PRAGMA integrity_check").fetchone()[0] == 'ok' + assert con.execute("SELECT title,platform,ai_provider FROM listings WHERE id=1").fetchone() == ('Bestehender Artikel','vinted','openrouter') + assert con.execute("SELECT created_by,updated_by FROM listings WHERE id=1").fetchone() == (uid, uid) + try: + con.execute("INSERT INTO resource_locks(resource_type,resource_id,user_id,token_hash,expires_at) VALUES ('listing','1',?,'hash2',datetime('now','+2 minutes'))", (uid,)) + raise AssertionError('Doppelte Sperre wurde nicht blockiert') + except sqlite3.IntegrityError: + pass +print('SQLite-Sicherheitsmigration 1.31.0 erfolgreich: bestehender Artikel erhalten, Benutzer-/Session-/Audit-/Lock-Struktur gültig.') diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice30-mock.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice30-mock.mjs new file mode 100644 index 0000000..481b992 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice30-mock.mjs @@ -0,0 +1,137 @@ +import http from 'http'; +import { once } from 'events'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { + generateWithLocalFlux, + getLocalFluxRuntimeInfo, + interruptLocalFlux, + cancelLocalFluxPrompt, +} from '../lib/ai-local-images.mjs'; + +let submittedWorkflow = null; +let promptSubmissions = 0; +let interrupted = false; +let deletedQueueIds = []; +const imageBytes = Buffer.from('vendoo-slice-30-mock-image'); + +const server = http.createServer(async (req, res) => { + const sendJson = (status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + if (req.method === 'GET' && req.url === '/system_stats') { + return sendJson(200, { system: { os: 'mock-os', comfyui_version: 'mock-1' }, devices: [{ name: 'Mock GPU', vram_total: 16 * 1024 ** 3, vram_free: 12 * 1024 ** 3 }] }); + } + if (req.method === 'GET' && req.url === '/queue') { + return sendJson(200, { + queue_running: [[1, 'mock-running', {}, {}, []]], + queue_pending: [[2, 'mock-pending', {}, {}, []]], + }); + } + if (req.method === 'POST' && req.url === '/queue') { + let body = ''; + for await (const chunk of req) body += chunk; + deletedQueueIds = JSON.parse(body || '{}').delete || []; + return sendJson(200, { ok: true }); + } + if (req.method === 'GET' && req.url.startsWith('/history?')) { + return sendJson(200, { 'mock-prompt': { outputs: {} } }); + } + if (req.method === 'POST' && req.url === '/interrupt') { + interrupted = true; + return sendJson(200, { ok: true }); + } + if (req.method === 'POST' && req.url === '/prompt') { + promptSubmissions += 1; + let body = ''; + for await (const chunk of req) body += chunk; + submittedWorkflow = JSON.parse(body).prompt; + return sendJson(200, { prompt_id: 'slice30-prompt-id' }); + } + if (req.method === 'GET' && req.url === '/history/slice30-prompt-id') { + return sendJson(200, { + 'slice30-prompt-id': { + outputs: { + 9: { images: [{ filename: 'slice30.png', subfolder: '', type: 'output' }] }, + }, + }, + }); + } + if (req.method === 'GET' && req.url.startsWith('/view?')) { + res.writeHead(200, { 'Content-Type': 'image/png' }); + return res.end(imageBytes); + } + return sendJson(404, { error: `Unhandled ${req.method} ${req.url}` }); +}); + +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const address = server.address(); +const settings = { + ai_model_photo_local_enabled: '1', + ai_model_photo_local_url: `http://127.0.0.1:${address.port}`, + ai_model_photo_local_workflow_path: resolve('local-ai/workflows/vendoo_flux_schnell_api.json'), + ai_model_photo_use_source_reference: '0', + ai_model_photo_local_resolution: '768', +}; + +try { + const phases = []; + const result = await generateWithLocalFlux({ + prompt: 'Studio product photo with soft light', + settings, + mode: 'prompt', + sourceDataUrl: '', + width: 768, + height: 768, + seed: 1234, + steps: 4, + onPhase: (phase) => phases.push(phase), + }); + if (result.prompt_id !== 'slice30-prompt-id') throw new Error('Prompt-ID wurde nicht zurückgegeben.'); + if (Buffer.from(result.base64, 'base64').compare(imageBytes) !== 0) throw new Error('Mock-Bild stimmt nicht.'); + const classTypes = Object.values(submittedWorkflow || {}).map(node => node?.class_type).filter(Boolean); + if (classTypes.includes('LoadImage')) throw new Error('Promptmodus enthält unerlaubten LoadImage-Knoten.'); + if (!phases.includes('submitted') || !phases.includes('rendering') || !phases.includes('completed')) throw new Error(`Phasen unvollständig: ${phases.join(', ')}`); + + let cancelledBeforeSubmit = false; + try { + await generateWithLocalFlux({ + prompt: 'This job must not be submitted', settings, mode: 'prompt', isCancelled: () => true, + }); + } catch (error) { + cancelledBeforeSubmit = error?.code === 'FLUX_CANCELLED'; + } + if (!cancelledBeforeSubmit || promptSubmissions !== 1) throw new Error('Abbruch vor der ComfyUI-Übergabe ist nicht sicher.'); + + const runtime = await getLocalFluxRuntimeInfo(settings, { historyLimit: 20 }); + if (!runtime.reachable || runtime.queue_summary.running !== 1 || runtime.queue_summary.pending !== 1) throw new Error('Queue/System-Diagnose ist falsch.'); + if (runtime.system_stats?.devices?.[0]?.name !== 'Mock GPU') throw new Error('GPU-Systemdaten fehlen.'); + + const pendingCancel = await cancelLocalFluxPrompt('mock-pending', settings); + if (pendingCancel.mode !== 'dequeued' || !deletedQueueIds.includes('mock-pending')) throw new Error('Gezieltes Entfernen eines wartenden Auftrags ist fehlgeschlagen.'); + const runningCancel = await cancelLocalFluxPrompt('mock-running', settings); + if (runningCancel.mode !== 'interrupted' || !interrupted) throw new Error('Laufender Auftrag wurde nicht unterbrochen.'); + await interruptLocalFlux(settings); + if (!interrupted) throw new Error('Interrupt-Endpunkt wurde nicht aufgerufen.'); + + const rawWorkflow = JSON.parse(readFileSync(settings.ai_model_photo_local_workflow_path, 'utf8').replace(/^\uFEFF/, '')); + if (Object.values(rawWorkflow).some(node => node?.class_type === 'LoadImage')) throw new Error('Ausgelieferter freier Promptworkflow enthält LoadImage.'); + + console.log(JSON.stringify({ + ok: true, + prompt_id: result.prompt_id, + phases, + queue_summary: runtime.queue_summary, + gpu: runtime.system_stats.devices[0].name, + interrupt: interrupted, + targeted_pending_cancel: deletedQueueIds.includes('mock-pending'), + targeted_running_cancel: runningCancel.mode === 'interrupted', + cancelled_before_submit: cancelledBeforeSubmit, + prompt_submissions: promptSubmissions, + load_image_in_prompt_workflow: false, + }, null, 2)); +} finally { + server.close(); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice31-image-editor.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice31-image-editor.mjs new file mode 100644 index 0000000..aa89f82 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice31-image-editor.mjs @@ -0,0 +1,90 @@ +import sharp from 'sharp'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { renderImageVersion, resolveEditableImage, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-slice31-')); +const uploadRoot = join(temp, 'uploads'); +const sourceFolder = join(uploadRoot, 'verification'); +mkdirSync(sourceFolder, { recursive: true }); +const sourcePath = join(sourceFolder, 'original.png'); + +try { + await sharp({ + create: { width: 960, height: 720, channels: 4, background: { r: 225, g: 213, b: 192, alpha: 1 } }, + }).composite([{ + input: Buffer.from(''), + }]).png().toFile(sourcePath); + + const normalized = normalizeEditorOperations({ + rotation: 90, + straighten: 2.5, + flip_h: true, + crop: { x: 0.08, y: 0.1, width: 0.84, height: 0.76 }, + exposure: 0.3, + brightness: 12, + contrast: 18, + highlights: -24, + shadows: 28, + temperature: 16, + saturation: 14, + sharpness: 38, + resize: { width: 640, height: 640 }, + remove_background: true, + watermark: { enabled: true, text: 'Vendoo Test', position: 'bottom-right', opacity: 55 }, + format: 'png', + quality: 84, + }); + + let recorded = null; + const result = await renderImageVersion({ + uploadRoot, + sourcePath: 'verification/original.png', + rootPath: 'verification/original.png', + context: 'verification', + operations: normalized, + recordVersion: data => { + recorded = data; + return { id: 1, ...data }; + }, + }); + + const metadata = await sharp(join(uploadRoot, result.filename)).metadata(); + if (metadata.format !== 'png') throw new Error(`Falsches Ausgabeformat: ${metadata.format}`); + if (metadata.width > 640 || metadata.height > 640) throw new Error(`Zielgröße überschritten: ${metadata.width}x${metadata.height}`); + if (!recorded || recorded.root_path !== 'verification/original.png') throw new Error('Versionenkette wurde nicht korrekt protokolliert.'); + if (recorded.output_path !== result.filename || recorded.source_path !== 'verification/original.png') throw new Error('Quell-/Zielpfad der Version ist inkonsistent.'); + if (!recorded.operations.crop || recorded.operations.sharpness !== 38) throw new Error('Bearbeitungsparameter wurden nicht vollständig gespeichert.'); + if (!recorded.operations.remove_background) throw new Error('Hintergrundentfernung wurde nicht gespeichert.'); + if (!recorded.operations.watermark?.enabled || recorded.operations.watermark.text !== 'Vendoo Test') throw new Error('Wasserzeichen wurde nicht gespeichert.'); + const stats = await sharp(join(uploadRoot, result.filename)).stats(); + const alpha = stats.channels[3]; + if (!alpha || alpha.min !== 0 || alpha.max !== 255) throw new Error('Transparenter Hintergrund wurde nicht erzeugt.'); + + let traversalBlocked = false; + try { resolveEditableImage(uploadRoot, '../outside.png'); } catch { traversalBlocked = true; } + if (!traversalBlocked) throw new Error('Pfadschutz hat Traversal nicht blockiert.'); + + console.log(JSON.stringify({ + ok: true, + source: { width: 960, height: 720, format: 'png' }, + output: { width: metadata.width, height: metadata.height, format: metadata.format, filename: result.filename }, + operations: { + undo_redo_state_shape: true, + crop: recorded.operations.crop, + rotation: recorded.operations.rotation, + straighten: recorded.operations.straighten, + tone_controls: ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness'], + resize: recorded.operations.resize, + quality: recorded.operations.quality, + remove_background: recorded.operations.remove_background, + watermark: recorded.operations.watermark, + }, + non_destructive: true, + version_recorded: true, + traversal_blocked: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-batch.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-batch.mjs new file mode 100644 index 0000000..ec4bfea --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-batch.mjs @@ -0,0 +1,72 @@ +import { rmSync, mkdirSync, existsSync, unlinkSync } from 'fs'; +import { resolve } from 'path'; +import sharp from 'sharp'; + +const root = resolve('.'); +const dbPath = resolve(root, 'db', 'vendoo.db'); +const uploadRoot = resolve(root, 'uploads'); +rmSync(dbPath, { force: true }); +rmSync(`${dbPath}-wal`, { force: true }); +rmSync(`${dbPath}-shm`, { force: true }); +rmSync(uploadRoot, { recursive: true, force: true }); +mkdirSync(resolve(uploadRoot, 'test'), { recursive: true }); +await sharp({ create: { width: 900, height: 1200, channels: 3, background: '#d8c1a7' } }).png().toFile(resolve(uploadRoot, 'test', 'produkt.png')); + +const batch = await import('../lib/image-batch-jobs.mjs'); +const { db } = await import('../lib/db.mjs'); +const profiles = batch.listImageBatchProfiles(); +if (profiles.length < 8) throw new Error(`Nur ${profiles.length} integrierte Profile gefunden.`); +const custom = batch.createImageBatchProfile({ + name: 'Test WebP', description: 'Verifikation', filename_pattern: '{name}-test-{index}', + operations: { resize: { width: 640, height: 640, fit: 'contain', background: '#ffffff' }, format: 'webp', quality: 82 }, +}); +if (!custom?.id || custom.is_builtin) throw new Error('Eigenes Profil wurde nicht korrekt angelegt.'); + +const job = batch.createImageBatchJob({ source_paths: ['test/produkt.png'], profile_ids: [custom.id], name: 'Batch-Verifikation' }); +batch.startImageBatchJobLoop(); +const waitFor = async (id, accepted, timeout = 15000) => { + const started = Date.now(); + while (Date.now() - started < timeout) { + const current = batch.getImageBatchJob(id); + if (accepted.includes(current?.status)) return current; + await new Promise(resolveWait => setTimeout(resolveWait, 100)); + } + throw new Error(`Timeout für Auftrag ${id}`); +}; +const completed = await waitFor(job.id, ['completed','partial','failed']); +if (completed.status !== 'completed') throw new Error(`Batch-Auftrag endete mit ${completed.status}: ${completed.items?.[0]?.error_message || ''}`); +const output = completed.items[0]?.output_path; +if (!output || !existsSync(resolve(uploadRoot, output))) throw new Error('Batch-Ausgabedatei fehlt.'); +const meta = await sharp(resolve(uploadRoot, output)).metadata(); +if (meta.width !== 640 || meta.height !== 640 || meta.format !== 'webp') throw new Error(`Falsche Ausgabe: ${meta.width}x${meta.height} ${meta.format}`); +const version = db.prepare("SELECT * FROM image_edit_versions WHERE output_path = ? AND context = 'batch'").get(output); +if (!version) throw new Error('Nicht destruktive Batch-Version fehlt in image_edit_versions.'); + +await sharp({ create: { width: 700, height: 500, channels: 3, background: '#eeeeee' } }).jpeg().toFile(resolve(uploadRoot, 'test', 'pause.jpg')); +const pausedJob = batch.createImageBatchJob({ source_paths: ['test/pause.jpg'], profile_ids: [profiles[0].id], name: 'Pause-Verifikation' }); +batch.pauseImageBatchJob(pausedJob.id); +await new Promise(resolveWait => setTimeout(resolveWait, 300)); +if (batch.getImageBatchJob(pausedJob.id).status !== 'paused') throw new Error('Pause wurde nicht persistiert.'); +batch.resumeImageBatchJob(pausedJob.id); +const resumed = await waitFor(pausedJob.id, ['completed','partial','failed']); +if (resumed.status !== 'completed') throw new Error('Fortgesetzter Auftrag wurde nicht fertig.'); + +await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ffffff' } }).png().toFile(resolve(uploadRoot, 'test', 'fail.png')); +const retryJob = batch.createImageBatchJob({ source_paths: ['test/fail.png'], profile_ids: [custom.id], name: 'Retry-Verifikation' }); +unlinkSync(resolve(uploadRoot, 'test', 'fail.png')); +const failed = await waitFor(retryJob.id, ['failed','partial']); +if (failed.status !== 'failed') throw new Error('Fehlersimulation wurde nicht als fehlgeschlagen erkannt.'); +await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ffffff' } }).png().toFile(resolve(uploadRoot, 'test', 'fail.png')); +batch.retryImageBatchJob(retryJob.id); +const retried = await waitFor(retryJob.id, ['completed','partial','failed']); +if (retried.status !== 'completed') throw new Error('Wiederholung war nicht erfolgreich.'); + +const outputs = batch.getImageBatchOutputPaths(job.id); +if (outputs.length !== 1) throw new Error('ZIP-Ausgabeliste ist inkorrekt.'); +console.log(JSON.stringify({ ok: true, profiles: profiles.length, output, dimensions: `${meta.width}x${meta.height}`, pause_resume: true, retry: true }, null, 2)); + +db.close(); +rmSync(dbPath, { force: true }); +rmSync(`${dbPath}-wal`, { force: true }); +rmSync(`${dbPath}-shm`, { force: true }); +rmSync(uploadRoot, { recursive: true, force: true }); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-image-render.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-image-render.mjs new file mode 100644 index 0000000..aaef971 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-slice32-image-render.mjs @@ -0,0 +1,31 @@ +import { mkdirSync, rmSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import sharp from 'sharp'; +import { renderImageVersion } from '../lib/image-editor.mjs'; + +const root = resolve('tmp-slice32-render'); +rmSync(root, { recursive: true, force: true }); +mkdirSync(resolve(root, 'source'), { recursive: true }); +await sharp({ create: { width: 900, height: 1200, channels: 3, background: '#d8c1a7' } }).png().toFile(resolve(root, 'source', 'produkt.png')); +let recorded = null; +const result = await renderImageVersion({ + uploadRoot: root, + sourcePath: 'source/produkt.png', + context: 'batch', + rootPath: 'source/produkt.png', + outputDirectory: 'batch/test-job', + outputFilename: 'produkt-webshop-01', + operations: { + resize: { width: 640, height: 640, fit: 'contain', background: '#ffffff' }, + format: 'webp', quality: 82, sharpness: 12, + watermark: { enabled: true, text: 'Vendoo', position: 'bottom-right', opacity: 30 }, + }, + recordVersion: value => { recorded = value; return { id: 1, ...value }; }, +}); +if (!existsSync(resolve(root, result.filename))) throw new Error('Batch-Ausgabe fehlt.'); +const meta = await sharp(resolve(root, result.filename)).metadata(); +if (meta.width !== 640 || meta.height !== 640 || meta.format !== 'webp') throw new Error(`Falsche Ausgabe ${meta.width}x${meta.height} ${meta.format}`); +if (!result.filename.startsWith('batch/test-job/produkt-webshop-01')) throw new Error(`Falscher Ausgabeordner: ${result.filename}`); +if (recorded?.context !== 'batch' || recorded?.output_path !== result.filename) throw new Error('Nicht destruktiver Versionsdatensatz ist inkorrekt.'); +console.log(JSON.stringify({ ok: true, output: result.filename, width: meta.width, height: meta.height, format: meta.format, version_context: recorded.context }, null, 2)); +rmSync(root, { recursive: true, force: true }); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-theme-manager-1.36.0.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-theme-manager-1.36.0.mjs new file mode 100644 index 0000000..428341b --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-theme-manager-1.36.0.mjs @@ -0,0 +1,76 @@ +import { readFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'child_process'; +import { + evaluateThemeAccessibility, getThemeContract, validateThemeDefinition, validateThemePreference, +} from '../app/core/themes/theme-contract.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = (fn, code, message) => { + try { fn(); failures.push(message); } + catch (error) { if (error.code !== code) failures.push(`${message} (erhalten ${error.code || error.message})`); } +}; + +const pkg = JSON.parse(read('package.json')); +assert(pkg.version === '1.36.0', `Paketversion ist ${pkg.version}`); +const source = JSON.parse(read('public/design-system/tokens/source.json')); +assert(source.contractVersion === '1.1.0', `Tokenvertrag ist ${source.contractVersion}`); +assert(Object.keys(source.tokens).length >= 42, `Zu wenige Tokens: ${Object.keys(source.tokens).length}`); +for (const token of ['component.buttonHeight','component.inputHeight','component.cardRadius','component.focusWidth','component.touchTarget']) { + assert(Boolean(source.tokens[token]), `Komponenten-Token fehlt: ${token}`); +} + +const contract = getThemeContract(); +assert(contract.tokens['color.brand'].label === 'Akzentfarbe', 'Token-Label fehlt'); +assert(contract.tokens['component.buttonHeight'].group === 'Komponenten', 'Token-Gruppe fehlt'); +const valid = validateThemeDefinition({ id: 'custom-markus-pro', name: 'Markus Pro', base_theme: 'dark', values: { 'color.brand': '#ff6600', 'component.buttonHeight': '44px' } }); +assert(valid.id === 'custom-markus-pro', 'Gültiges Theme wurde nicht akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: {} }), 'THEME_ID_PREFIX', 'Theme ohne custom-Präfix wurde akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'color.brand': 'url(javascript:alert(1))' } }), 'THEME_VALUE_INVALID', 'CSS-Injektion wurde akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'shadow.float': '0 0 1px red' } }), 'THEME_TOKEN_FORBIDDEN', 'Nicht anpassbarer Token wurde akzeptiert'); +expectThrow(() => validateThemePreference({ theme_id: 'missing', density: 'comfortable', reduced_motion: 'system' }), 'THEME_NOT_FOUND', 'Unbekanntes Theme wurde akzeptiert'); +const poorContrast = evaluateThemeAccessibility({ baseTheme: 'light', values: { 'color.ink': '#ffffff', 'color.canvas': '#ffffff', 'color.surface': '#ffffff' } }); +assert(!poorContrast.ok && poorContrast.failures.length >= 2, 'Kontrastfehler wurde nicht erkannt'); + +const before = read('public/design-system/tokens.css'); +const generated = spawnSync(process.execPath, [join(root, 'tools/generate-design-tokens.mjs')], { cwd: root, encoding: 'utf8' }); +assert(generated.status === 0, `Token-Generator fehlgeschlagen: ${generated.stderr || generated.stdout}`); +assert(before === read('public/design-system/tokens.css'), 'Token-Generator ist nicht deterministisch'); + +const manifest = JSON.parse(read('app/modules/themes/module.json')); +assert(manifest.status === 'native', 'Theme-Modul ist nicht nativ'); +assert(manifest.provides.includes('themes.accessibility'), 'Accessibility-Capability fehlt'); +for (const file of [ + 'app/modules/themes/index.mjs','app/modules/themes/service.mjs','app/modules/themes/repository.mjs', + 'app/modules/themes/migrations/001_theme_profiles.sql','public/modules/theme-manager/theme-manager.js', + 'public/modules/theme-manager/theme-manager.css', +]) assert(read(file).length > 50, `Theme-Pflichtdatei fehlt oder ist leer: ${file}`); + +const server = read('server.mjs'); +for (const marker of [ + "id: 'themes.catalog.read'", "id: 'themes.preference.update'", "id: 'themes.profile.create'", + "id: 'themes.profile.update'", "id: 'themes.profile.delete'", "id: 'themes.system-default.update'", + "policy: 'themes.manage'", "policy: 'themes.use'", +]) assert(server.includes(marker), `Theme-Route fehlt: ${marker}`); +assert(server.indexOf("path: '/api/themes/system-default'") < server.indexOf("path: '/api/themes/:id'"), 'Systemstandard-Route wird von :id überschattet'); + +const html = read('public/index.html'); +for (const marker of ['id="theme-manager-root"','id="theme-choice-grid"','id="theme-token-groups"','id="theme-contrast-list"','theme-manager.js?v=1.36.0']) assert(html.includes(marker), `Theme-Manager UI fehlt: ${marker}`); +const manager = read('public/modules/theme-manager/theme-manager.js'); +assert(!manager.includes('.innerHTML ='), 'Theme Manager verwendet unsicheres innerHTML'); +for (const marker of ['/api/themes/validate','/api/themes/preference','/api/themes/system-default','window.VendooTheme?.previewTheme']) assert(manager.includes(marker), `Theme Manager Logik fehlt: ${marker}`); +const runtime = read('public/design-system/theme-runtime.js'); +for (const marker of ['vendoo-theme-state-v2','dataset.reducedMotion','clearCustomProperties','previewTheme']) assert(runtime.includes(marker), `Theme Runtime fehlt: ${marker}`); +const schema = read('db/schema.sql'); +for (const marker of ['CREATE TABLE IF NOT EXISTS theme_profiles','CREATE TABLE IF NOT EXISTS user_theme_preferences']) assert(schema.includes(marker), `Theme-Schema fehlt: ${marker}`); + +if (failures.length) { + console.error(`Theme-Manager-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log(`Theme-Manager-Gate 1.36.0 erfolgreich: ${Object.keys(source.tokens).length} Tokens, natives Theme-Modul, sichere Profile, Benutzerpräferenzen, Kontrastprüfung und tokenisierte Komponenten geprüft.`); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-ui-contracts-1.26.1.mjs b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-ui-contracts-1.26.1.mjs new file mode 100644 index 0000000..d14318c --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/tools/verify-ui-contracts-1.26.1.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const js = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicates.length) throw new Error(`Doppelte HTML-IDs: ${duplicates.join(', ')}`); + +const navTabs = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +const sectionIds = new Set([...html.matchAll(/]*class="[^"]*tab-content/g)].map(match => match[1])); +const missingSections = [...new Set(navTabs)].filter(tab => !sectionIds.has(tab)); +if (missingSections.length) throw new Error(`Navigation ohne Zielbereich: ${missingSections.join(', ')}`); + +const requiredIds = [ + 'connection-banner', 'connection-retry-btn', 'system-diagnostics-btn', + 'system-diagnostics-copy', 'system-diagnostics-summary', 'system-diagnostics-grid', +]; +for (const id of requiredIds) if (!ids.includes(id)) throw new Error(`Pflicht-ID fehlt: ${id}`); + +const requiredJs = [ + 'setupGlobalResilience()', 'setupResponsiveContracts()', 'runSystemDiagnostics', + "'/api/admin/diagnostics'", '`/healthz?t=${Date.now()}`', 'recordClientIncident', + 'ResizeObserver', 'X-Vendoo-Request-ID', '--workspace-width', +]; +for (const marker of requiredJs) if (!js.includes(marker)) throw new Error(`UI-Vertrag fehlt: ${marker}`); + +const requiredCss = [ + '.connection-banner', '.diagnostics-grid', '.diagnostic-card', + '[data-layout-width="phone"]', +]; +for (const marker of requiredCss) if (!css.includes(marker)) throw new Error(`Responsive-/Diagnose-CSS fehlt: ${marker}`); + +const staleLabels = ['+ Neues Listing', '>Listings<', 'Listing generieren']; +for (const label of staleLabels) if (html.includes(label)) throw new Error(`Veraltete UI-Bezeichnung gefunden: ${label}`); + +console.log(JSON.stringify({ + ok: true, + html_ids: ids.length, + navigation_targets: [...new Set(navTabs)].length, + sections: sectionIds.size, + responsive_contract: true, + global_error_capture: true, + system_diagnostics_ui: true, +}, null, 2)); diff --git a/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/update-manifest.json b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/update-manifest.json new file mode 100644 index 0000000..c83a032 --- /dev/null +++ b/Archiv/Vendoo_Feature_1_36_0_Secure_Theme_Manager_Accessibility_Component_Tokens/vendoo/update-manifest.json @@ -0,0 +1,10 @@ +{ + "schema": "vendoo-update-manifest-v1", + "version": "1.36.0", + "name": "Vendoo Modular Platform Kernel & Design-System Foundation", + "channel": "stable", + "minimum_node": "18", + "notes": "Modularer Plattformkernel, validierte Modulmanifeste, Deny-by-default-Routenverträge, Feature Flags, Lifecycle/Event Bus sowie versionierte Design Tokens mit sicherem Theme-Vertrag. Bestehendes Verhalten bleibt über Legacy-Bridges erhalten.", + "download_url": "", + "sha256": "" +} diff --git a/Archiv/mitFlux/Vendoo_Feature_1_24_0_Responsive_FLUX_Archiv_Bildergalerie.zip b/Archiv/mitFlux/Vendoo_Feature_1_24_0_Responsive_FLUX_Archiv_Bildergalerie.zip new file mode 100644 index 0000000..eb34ce9 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_24_0_Responsive_FLUX_Archiv_Bildergalerie.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_25_0_Responsive_Gallery_SmartCopy_FLUX_PromptLab.zip b/Archiv/mitFlux/Vendoo_Feature_1_25_0_Responsive_Gallery_SmartCopy_FLUX_PromptLab.zip new file mode 100644 index 0000000..63c65a9 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_25_0_Responsive_Gallery_SmartCopy_FLUX_PromptLab.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_26_0_Artikel_Extensions_Publish_Gallery.zip b/Archiv/mitFlux/Vendoo_Feature_1_26_0_Artikel_Extensions_Publish_Gallery.zip new file mode 100644 index 0000000..76469a5 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_26_0_Artikel_Extensions_Publish_Gallery.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_28_0_Artikel_Quality_Center.zip b/Archiv/mitFlux/Vendoo_Feature_1_28_0_Artikel_Quality_Center.zip new file mode 100644 index 0000000..c7e040e Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_28_0_Artikel_Quality_Center.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_30_0_Operations_Center.zip b/Archiv/mitFlux/Vendoo_Feature_1_30_0_Operations_Center.zip new file mode 100644 index 0000000..5158953 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_30_0_Operations_Center.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip b/Archiv/mitFlux/Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip new file mode 100644 index 0000000..11b2369 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_32_0_Docker_NAS_Production_Deployment_Foundation.zip b/Archiv/mitFlux/Vendoo_Feature_1_32_0_Docker_NAS_Production_Deployment_Foundation.zip new file mode 100644 index 0000000..de38628 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_32_0_Docker_NAS_Production_Deployment_Foundation.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_33_0_Multiplatform_Deployment_Wizard_Flux_Admin_Fixes.zip b/Archiv/mitFlux/Vendoo_Feature_1_33_0_Multiplatform_Deployment_Wizard_Flux_Admin_Fixes.zip new file mode 100644 index 0000000..cf39de7 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_33_0_Multiplatform_Deployment_Wizard_Flux_Admin_Fixes.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_34_0_GitHub_CICD_Zero_Edit_Installer.zip b/Archiv/mitFlux/Vendoo_Feature_1_34_0_GitHub_CICD_Zero_Edit_Installer.zip new file mode 100644 index 0000000..d8da636 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_34_0_GitHub_CICD_Zero_Edit_Installer.zip differ diff --git a/Archiv/mitFlux/Vendoo_Feature_1_34_1_Git_Safety_Gate_Windows_Hotfix.zip b/Archiv/mitFlux/Vendoo_Feature_1_34_1_Git_Safety_Gate_Windows_Hotfix.zip new file mode 100644 index 0000000..1c1eadf Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Feature_1_34_1_Git_Safety_Gate_Windows_Hotfix.zip differ diff --git a/Archiv/mitFlux/Vendoo_Hotfix_31_1_Editor_Vinted_1.23.1.zip b/Archiv/mitFlux/Vendoo_Hotfix_31_1_Editor_Vinted_1.23.1.zip new file mode 100644 index 0000000..bb1fe2f Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Hotfix_31_1_Editor_Vinted_1.23.1.zip differ diff --git a/Archiv/mitFlux/Vendoo_Hotfix_31_2_FLUX_Editor_1.23.2.zip b/Archiv/mitFlux/Vendoo_Hotfix_31_2_FLUX_Editor_1.23.2.zip new file mode 100644 index 0000000..4d09ff8 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Hotfix_31_2_FLUX_Editor_1.23.2.zip differ diff --git a/Archiv/mitFlux/Vendoo_Slice_28_1_FLUX_Runtime_Healthcheck_1.20.1.zip b/Archiv/mitFlux/Vendoo_Slice_28_1_FLUX_Runtime_Healthcheck_1.20.1.zip new file mode 100644 index 0000000..3dc1a17 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Slice_28_1_FLUX_Runtime_Healthcheck_1.20.1.zip differ diff --git a/Archiv/mitFlux/Vendoo_Slice_29_FLUX_Quality_Settings_Tabs_1.21.0.zip b/Archiv/mitFlux/Vendoo_Slice_29_FLUX_Quality_Settings_Tabs_1.21.0.zip new file mode 100644 index 0000000..db6c8ca Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Slice_29_FLUX_Quality_Settings_Tabs_1.21.0.zip differ diff --git a/Archiv/mitFlux/Vendoo_Slice_30_FLUX_Studio_Pro_1.22.0.zip b/Archiv/mitFlux/Vendoo_Slice_30_FLUX_Studio_Pro_1.22.0.zip new file mode 100644 index 0000000..f4eaa30 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Slice_30_FLUX_Studio_Pro_1.22.0.zip differ diff --git a/Archiv/mitFlux/Vendoo_Slice_31_Advanced_Image_Editor_1.23.0.zip b/Archiv/mitFlux/Vendoo_Slice_31_Advanced_Image_Editor_1.23.0.zip new file mode 100644 index 0000000..81e6c14 Binary files /dev/null and b/Archiv/mitFlux/Vendoo_Slice_31_Advanced_Image_Editor_1.23.0.zip differ diff --git a/Archiv/neuer chat/VENDOO_NEW_CHAT_HANDOVER_1_31_0_2026_07_09.zip b/Archiv/neuer chat/VENDOO_NEW_CHAT_HANDOVER_1_31_0_2026_07_09.zip new file mode 100644 index 0000000..378f3c3 Binary files /dev/null and b/Archiv/neuer chat/VENDOO_NEW_CHAT_HANDOVER_1_31_0_2026_07_09.zip differ diff --git a/Archiv/vorFlux/7z2602-x64.exe b/Archiv/vorFlux/7z2602-x64.exe new file mode 100644 index 0000000..c85ef8b Binary files /dev/null and b/Archiv/vorFlux/7z2602-x64.exe differ diff --git a/Archiv/vorFlux/IconLogoMockup.png b/Archiv/vorFlux/IconLogoMockup.png new file mode 100644 index 0000000..5e0a8fe Binary files /dev/null and b/Archiv/vorFlux/IconLogoMockup.png differ diff --git a/Archiv/vorFlux/Vendoo_Slice_19.zip b/Archiv/vorFlux/Vendoo_Slice_19.zip new file mode 100644 index 0000000..8bf2170 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_Slice_19.zip differ diff --git a/Archiv/vorFlux/Vendoo_Slice_19_PS51_Fixed.zip b/Archiv/vorFlux/Vendoo_Slice_19_PS51_Fixed.zip new file mode 100644 index 0000000..e5eba94 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_Slice_19_PS51_Fixed.zip differ diff --git a/Archiv/vorFlux/Vendoo_Slice_21_FLUX_Installer_Status_Hotfix.zip b/Archiv/vorFlux/Vendoo_Slice_21_FLUX_Installer_Status_Hotfix.zip new file mode 100644 index 0000000..aacd8db Binary files /dev/null and b/Archiv/vorFlux/Vendoo_Slice_21_FLUX_Installer_Status_Hotfix.zip differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/.gitignore b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/.gitignore new file mode 100644 index 0000000..c428aae --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/.gitignore @@ -0,0 +1,43 @@ +# Secrets and local configuration +.env +.env.* +!.env.example +!.env.docker.example +*.pem +*.key +*.p12 +*.pfx +*.crt + +# Dependencies +node_modules/ + +# SQLite runtime data +db/*.db +db/*.db-shm +db/*.db-wal +db/*.sqlite +db/*.sqlite3 + +# User and runtime data +uploads/* +!uploads/.gitkeep +backups/ +logs/ +tmp/ +temp/ +sessions/ +data/ +docker-data/ +*.log + +# OS and editor files +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ + +# Local overrides +compose.override.yaml +docker-compose.override.yml diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CHANGELOG.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CHANGELOG.md new file mode 100644 index 0000000..11ab273 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CHANGELOG.md @@ -0,0 +1,270 @@ +## Vendoo 1.12.0 – OpenRouter Diagnostics & Mobile Mediathek + +- Mobiler QR-Upload besitzt jetzt getrennte Aktionen für **Kamera** und **Mediathek** +- Mehrfachauswahl aus der Smartphone-Mediathek ohne `capture`-Zwang +- HEIC/HEIF werden beim mobilen Upload nach Möglichkeit automatisch in JPG konvertiert +- OpenRouter-Free-Erkennung bewertet nur bildrelevante Preisfelder +- OpenRouter versucht je Modell mehrere kompatible Chat-Request-Varianten und danach die Images API +- Referenzbilder werden nur an Modelle gesendet, die Bildinput unterstützen +- Klare Diagnose, wenn aktuell **0 kostenlose Bildmodelle** verfügbar sind +- Bei Fehlern werden weiterhin alle passenden Free-Modelle nacheinander versucht; Auto fällt anschließend auf OpenAI zurück + +## Vendoo 1.11.0 – LAN QR Upload, OpenRouter Fallback & Mobile Nav Fix + +- QR-Handy-Upload verwendet automatisch eine erreichbare LAN-IP statt localhost +- Vendoo lauscht für lokale Netzwerkzugriffe auf `0.0.0.0` und zeigt erkannte LAN-Adressen beim Start an +- Neue Einstellung für `PUBLIC_BASE_URL` inklusive automatischer LAN-IP-Erkennung +- OpenRouter probiert bei Fehlern automatisch weitere passende Free-Bildmodelle und danach – im Auto-Modus – OpenAI +- OpenRouter-Bildgenerierung unterstützt Chat-Completions und Images-API als zweistufigen Fallback +- Mobile Navigation vollständig gehärtet: Overlay, Close-Button, Fokus, Escape, Scroll-Lock und responsive Labels + +## Vendoo 1.10.0 – AI Jobs, 1–3 Varianten & History + +- AI-Bildgenerierung läuft jetzt über eine Job-Queue statt nur synchron +- Mindestanzahl auf **1 Bild** gesenkt, Maximum bleibt **3 Bilder** +- Neue Modi: **Model**, **Ghost Mannequin**, **Flatlay** +- Listing-Editor zeigt jetzt eine **AI-Bild-Historie** zum schnellen Übernehmen früherer Generierungen +- Settings erweitert um Standard-Modus für AI-Bilder + +## Vendoo 1.9.0 – AI Provider Settings Center + +- Neuer Settings-Bereich für AI-Model-Fotos mit Provider-Steuerung +- Admin kann jetzt `auto`, `openrouter` oder `openai` für Bildgenerierung wählen +- OpenRouter-Free-Modelle können direkt aus der UI geladen und geprüft werden +- Safety-Flags, Standard-Preset, Variantenanzahl und Fallback-Modell sind in der UI konfigurierbar + +## Vendoo 1.8.0 – OpenRouter Free Image Discovery + +- AI-Model-Fotos unterstützen jetzt einen Provider-Modus `auto` +- OpenRouter Free-Bildmodelle werden dynamisch über die Models-API abgefragt und bei Verfügbarkeit bevorzugt genutzt +- Fallback auf OpenAI (`gpt-image-1` oder konfiguriertes Modell), wenn keine freien OpenRouter-Modelle verfügbar sind +- Neue API-Route für verfügbare OpenRouter-Bildmodelle sowie Persistenz von Provider und Modell je Generierung + +## Vendoo 1.7.0 – AI-Model-Fotos & Safety Layer + +- Neuer sicherer AI-Model-Foto-Workflow für Kleidung ohne Person auf dem Originalfoto +- 2–3 KI-generierte Zusatzbilder mit fiktiven erwachsenen Models in Studio-, Indoor- oder Outdoor-Looks +- Precheck & Output-Safety: blockiert Unterwäsche, Bademode, transparente Erotik-Looks, Kinderkleidung und Quellen mit Personen +- Neue Backend-Routen, Datenbanktabellen und Listing-Integration für Generator und Detailansicht + +# Changelog + +## [Unreleased] + +## [1.6.2] - 2026-07-07 + +### Branding + +- neues Vendoo-Hauptlogo in die App-Sidebar integriert +- neues Branding auf Login und mobilem QR-Fotoupload integriert +- vollständiges Favicon-Paket mit ICO, PNG, Apple Touch Icon und Webmanifest eingebunden +- mobile App-Icon-Größen von 16 bis 512 Pixel ergänzt +- Chrome-, Edge- und Firefox-Erweiterungen auf die neue Vendoo-Icon-Familie aktualisiert +- Vinted Assistant und minimierter Assistant verwenden das neue Logo + +## [1.6.1] - 2026-07-07 + +### Verbessert + +- Dashboard ist jetzt der erste Menüpunkt und die Standard-Startseite. +- Dashboard-KPI-Karten verwenden eine fließende Auto-Fit-Struktur statt starrer Sechserspalten. +- Sparklines besitzen eine reservierte Diagrammfläche und überlagern keine Texte mehr. +- Umsatzdiagramme werden bei Größenänderungen automatisch neu berechnet. +- Dashboard-Charts passen sich an Notebook-, Tablet- und Mobilbreiten an. +- Studio-Flow-Karten verwenden proportionale Bildflächen mit vollständiger `contain`-Darstellung. +- Produktbilder werden nicht mehr abgeschnitten und erhalten einen sauberen Fehlerzustand. +- Ready-Karten verteilen sich automatisch auf die verfügbare Breite. + +## [1.6.0] - 2026-07-07 + +### Hinzugefügt + +- neuer Sidebar-Menüpunkt **Dashboard** mit interaktiven Kennzahlen, Umsatzverlauf, Plattform- und Kategorieauswertung +- Dashboard-Bereiche für Fokusaufgaben, Top Seller, Aktivität, Benachrichtigungen, AI-Qualität und Systemstatus +- Fotoarchiv-Endpunkt: mehrere Fotos werden serverseitig als gültiges ZIP erzeugt +- Foto-Download im Generator und vollständigen Listing-Editor + +### Geändert + +- Studio Flow vollständig neu strukturiert: Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- die fachlich unpassenden sichtbaren Stufen „Neu erfasst“ und „AI-Prüfung“ wurden entfernt +- Studio Flow zeigt nur priorisierte Artikel statt einer überfüllten Spaltenwand +- geplante Listings, Probleme und Aktivitäten sind in klar getrennten Arbeitsbereichen organisiert +- Dashboard und Studio Flow für Notebook, iPad, Tablet und Smartphone neu priorisiert + +### Behoben + +- Publish-Center lädt Listings beim ersten Öffnen mit Wiederholungslogik und verhindert veraltete parallele Renderings +- Publish-Ladefehler zeigt jetzt eine konkrete Wiederholen-Aktion statt einer Sackgasse +- bei einem Foto erfolgt ein normaler Download, bei mehreren Fotos automatisch ein ZIP-Download + +## [1.5.0] - 2026-07-07 + +### Hinzugefügt + +- Browser-Erweiterungen in getrennten Ordnern für Chrome, Edge und Firefox +- vollständiger Design-Relaunch von Popup und Vinted Assistant +- neues Vendoo-Link-Icon in 16, 32, 48 und 128 Pixel +- QR-basierter Handy-Fotoupload für Generator und Listing-Editor +- sichere, zeitlich begrenzte Mobile-Upload-Sitzungen +- responsive Layout-Gates für Desktop, Notebook, iPad und Smartphone +- umschaltbare Plattformvorschau und HTML-Codeansicht + +### Verbessert + +- Produktbilder der Extension werden vollständig dargestellt +- CSRF-Unterstützung für schreibende Extension-Requests +- öffentliche Serveradresse statt festem localhost für Extension-Bilder +- Plattformvorschau und HTML-Code sind in Generator und Listing-Editor als Umschalter zusammengeführt +- Layouts für Desktop, Notebook, iPad und Smartphone neu priorisiert +- freigegebene Extension- und Icon-Mockups als verbindliche Designreferenz archiviert + +### Behoben + +- Studio Flow: „Prüfen & bearbeiten“ wechselt jetzt zuverlässig zur sichtbaren Listing-Detailansicht. +- Detailaufrufe aus Attention Queue und anderen Bereichen verwenden denselben sicheren Navigationspfad. +- Ungültige Listing-IDs werden abgefangen und als Hinweis angezeigt. + +- Windows-Installer behandelt npm `ENOTEMPTY`/`EPERM` robust, bereinigt unvollstaendige `node_modules`, protokolliert npm-Ausgaben und wiederholt die Installation einmal. +- Installation nutzt mit vorhandenem Lockfile reproduzierbar `npm ci` statt eines stillen `npm install`. + +### Geplant + +- Lager, Vorlagen, Einstellungen und Admin gemäß Design-Freeze +- Docker- und Online-Readiness + +## [1.4.0-full-listing-editor] - 2026-07-07 + +### Hinzugefügt + +- Listings lassen sich jetzt vollständig in einem eigenen Arbeitsbereich bearbeiten +- Fotoverwaltung mit Hinzufügen, Entfernen, Cover-Auswahl, Drag-to-Sort und vollständigem Bildeditor ergänzt +- WYSIWYG-Editor und HTML-Code sind im Listing-Editor bidirektional und live synchronisiert +- Generator zeigt HTML-Code und Plattformvorschau gleichzeitig; Änderungen werden ohne Umschalten übernommen +- Plattform-HTML wird während der Bearbeitung live neu gerendert +- neue Datenbankspalte `description_html` mit Migration und Rückwärtskompatibilität +- neuer API-Endpunkt `POST /api/html-render` für nicht persistierende Live-Vorschauen +- Editor-HTML und vollständiges Plattform-HTML können getrennt kopiert werden + +### Sicherheit + +- Rich-Text-HTML wird client- und serverseitig auf eine begrenzte Tag- und Attributmenge reduziert +- Plattformvorschauen laufen in sandboxed iframes +- Listing-Updates verwenden jetzt eine feste Feld-Allowlist statt beliebiger SQL-Spaltennamen + +### Behoben + +- `photos` werden bei Listing-Updates korrekt als JSON gespeichert +- Bildbearbeitung funktioniert nun auch für bereits gespeicherte Listings +- formatierte Beschreibungen bleiben nach Speichern und erneutem Öffnen erhalten + +## [1.3.0-publish-center] - 2026-07-07 + +### Geändert + +- Publish-Bereich als durchgängiges operatives Publish-Center neu aufgebaut +- Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung in einer gemeinsamen Oberfläche zusammengeführt +- bildorientierte Listing-Auswahl mit Suche, Plattform-/Statusfilter, Sortierung und Mehrfachauswahl ergänzt +- internes Workspace-Modell statt separater Queue- und Smart-Copy-Modale eingeführt +- direkte Listing-Bearbeitung vor Etsy-/eBay-Veröffentlichung integriert +- Publish-Bereitschaft, Pflichtfelder, Integrationsstatus und Fotoprüfung sichtbar gemacht +- Queue-Status, Schnellaktionen und Publish-Aktivität in einer festen Statusleiste gebündelt +- eBay-Fehler, Wiederholungen und geplante Veröffentlichungen inline bedienbar gemacht +- responsive Darstellung und Dark Mode für das komplette Publish-Center ergänzt + +### Behoben + +- Batch-Smart-Copy öffnete denselben Vorgang teilweise doppelt +- nicht passende Etsy-Batch-Auswahl wird jetzt gefiltert statt blind veröffentlicht +- Produktbilder im Publish-Center werden vollständig und ohne Beschnitt dargestellt +- Queue- und Planungsaktionen bleiben im Vendoo-Kontext und verlieren nicht den aktuellen Arbeitsstand + +## [1.2.0-listing-studio] - 2026-07-07 + +### Geändert + +- Listing-Generator als dreigeteilter Studio-Arbeitsplatz neu aufgebaut +- Foto-Cover, Sortierung, Qualitätscheck und vollständige Bildanpassung integriert +- AI-Konfiguration mit Plattform, Provider, Modell, Sprache, Varianten und Vorlagen neu strukturiert +- artikelspezifische Verkäufernotizen mit Zeichenzähler ergänzt +- Ergebnisbereich mit Live-Zählern, Qualitätswert, Attributen, Gebühren und HTML-Vorschau modernisiert +- responsive Darstellung und Dark Mode für den Generator vollständig ergänzt + +### API + +- `POST /api/generate` akzeptiert `template_id` und `seller_notes` +- globale Hinweise, Vorlagenhinweise und Artikelhinweise werden dedupliziert kombiniert +- Standard-Tags aus Vorlagen werden mit AI-Tags zusammengeführt + +### Behoben + +- Upload begrenzt neue Bilder clientseitig auf die verbleibenden Plätze bis maximal zehn +- Upload-Fehler werden mit HTTP-Status und Servermeldung korrekt angezeigt +- Dateinamen in Vorschaubildern werden URL-sicher ausgegeben + +## [1.0.1-ui-foundation.1] - 2026-07-07 + +### Hinzugefügt + +- zentrale Vendoo-Design-Tokens für Light und Dark Mode +- neue kompakte App-Shell und Hauptnavigation +- Command-Search-Einstieg mit `Ctrl/⌘ + K` +- neue Startseite `Studio Flow` +- Flow-Stufen: Neu erfasst, AI-Prüfung, Bereit, Geplant, Veröffentlicht, Verkauft +- Attention Queue und Activity Stream +- kontextueller Artikel-Inspector +- neuer Endpunkt `GET /api/dashboard/workflow` +- Dokumentation `docs/UI_FOUNDATION_2026.md` +- sicherer `.gitignore` + +### Sicherheit + +- `.env`, SQLite-Laufzeitdateien, Uploads und `node_modules` aus dem Übergabepaket entfernt +- keine produktiven Zugangsdaten in der bereinigten Version + +## [1.1.1-ui-polish] - 2026-07-07 + +### Geändert + +- Etiketten und Artikeldatenblätter öffnen jetzt im internen Vendoo Print Studio statt in einem neuen Browserfenster +- maßstabsgetreue A4-Vorschau mit zwei Etikettenformaten, Exemplaren und Preisoption +- Druck erfolgt im aktuellen Vendoo-Fenster über den normalen Systemdruckdialog +- Studio-Flow-Produktbilder verwenden vollständige automatische Anpassung (`object-fit: contain`) +- Inspector- und Attention-Bilder werden ebenfalls ohne Beschnitt dargestellt +- Vinted-Extension auf die freigegebene Vendoo-Designsprache 2026 umgestellt +- Extension-Popup und eingeblendetes Vinted-Panel modernisiert +- Produktbilder der Extension werden sicher über den Background-Service-Worker als Data-URL geladen +- Pfade von Upload-Bildern werden korrekt URL-codiert und Bildfehler erhalten einen sichtbaren Zustand +- fehlerhaft doppelt codierte deutsche Texte in der Extension repariert + +### Behoben + +- Produktvorschaubilder in der Vinted-Extension wurden durch Seiten-CSP/Mixed-Content-Konstellationen teilweise nicht angezeigt +- große oder hochformatige Produktbilder wurden im Studio Flow abgeschnitten + +## [1.1.0-ui-listings] - 2026-07-07 + +### Geändert + +- Listings-/Historie-Seite vollständig an die freigegebene Vendoo-Designrichtung 2026 angepasst +- neue Filterleiste mit Suche, Plattform, Status und Zeitraum +- benutzerdefinierbare sichtbare Tabellenspalten mit lokaler Speicherung +- dichte, bildorientierte Listing-Tabelle mit SKU, Plattform, AI-Provider, Preis, Status, Lagerort und Publish-Status +- neue Batch-Aktionsleiste für Publish, Nachbearbeitung, Preis, Status, Duplizieren, Etiketten und Löschen +- moderne Seitennavigation und Seitengrößensteuerung +- neue Exportauswahl für CSV und JSON +- verbesserter leerer Zustand und responsive Darstellung +- bestehende CRUD-, Export-, Druck-, Status- und Papierkorb-Funktionen erhalten + +## [1.6.1] - 2026-07-07 + +### Geändert + +- Vendoo Assistant für Chrome, Edge und Firefox vollständig verdichtet und visuell neu strukturiert. +- Queue-Karten zeigen Bild, Status, SKU, Preis und direkte Aktionen ohne große Leerflächen. +- „Nur ausfüllen“ ist jetzt die primäre und sichere Einzelaktion. +- Daten können in Vinted eingetragen werden, ohne automatisch einen Entwurf anzulegen oder zu veröffentlichen. +- „Daten + Bilder ausfüllen“ überträgt Bilder und Felder, führt aber keinen Abschluss aus. +- Entwurf und Veröffentlichung liegen bewusst in einem getrennten, aufklappbaren Abschlussbereich. +- Popup öffnet Vinted mit einem vorgemerkten Listing und füllt es nach dem Laden aus. +- Extension-Version auf 1.4.0 angehoben. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CLAUDE.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CLAUDE.md new file mode 100644 index 0000000..ad66dc4 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/CLAUDE.md @@ -0,0 +1,81 @@ +# Vendoo — Projekt-Kontext + +Du arbeitest am Projekt "Vendoo" — ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +Lies zuerst `FEATURES.md` im Projektroot — das ist die vollstaendige Feature-Dokumentation mit Tech Stack, Projektstruktur, DB-Schema, allen 80+ API-Endpunkten und 17 Feature-Beschreibungen. + +## Tech Stack + +- **Backend:** Node.js + Express (ESM, `"type": "module"`) +- **Frontend:** Vanilla HTML/CSS/JS — kein Framework, kein Build-Step, kein TypeScript +- **Datenbank:** SQLite via `better-sqlite3` (WAL-Modus) +- **AI:** Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) +- **Bilder:** `sharp` (Resize, BG-Remove, Watermark) +- **Rich-Text:** Quill Editor +- **E-Mail:** Nodemailer (SMTP) +- **Extension:** Chrome Manifest v3 + +## Dateistruktur (wo was hingehoert) + +| Aenderung | Datei(en) | +|---|---| +| Neue API-Route | `server.mjs` — Route hinzufuegen + Import erweitern | +| Neue DB-Funktion | `lib/db.mjs` — Funktion exportieren | +| Neue DB-Spalte | `lib/db.mjs` — MIGRATIONS-Array erweitern (try/catch ALTER TABLE Pattern) | +| Neues UI-Tab | `public/index.html` (Sidebar-Button + Section), `public/app.js` (TAB_TITLES + switchToTab + Logik), `public/style.css` | +| Neuer AI-Provider | `lib/ai-*.mjs` + `lib/ai-router.mjs` erweitern | +| Neue Plattform | `platforms/*.mjs` + `lib/categories.mjs` + `lib/fees.mjs` + `lib/html-templates.mjs` | +| Styling | `public/style.css` — CSS Custom Properties fuer Theming | + +## Konventionen + +- **Sprache:** UI ist komplett Deutsch +- **SKU-Format:** `VD-XXXX` (auto-generiert, 4-stellig aufsteigend) +- **Navigation:** Sidebar mit `data-tab` Attributen, Tab-Inhalte als `
                          ` +- **Topbar-Titel:** `TAB_TITLES` Map in `app.js` muss fuer neue Tabs erweitert werden +- **Dark Mode:** CSS-Variablen unter `[data-theme="dark"]`, niemals hardcoded Farben +- **Auth:** Session-basiert, Rollen `admin`/`editor`, CSRF-Token bei state-changing Requests +- **DB-Migrationen:** Neue Spalten als Eintrag in `MIGRATIONS`-Array (Zeile ~17-21 in db.mjs): + ```js + const MIGRATIONS = [ + ['spaltenname', 'TEXT'], // oder 'REAL', "TEXT DEFAULT 'wert'" + ]; + // Wird automatisch per try/catch ALTER TABLE ausgefuehrt + ``` +- **API-Pattern:** REST, JSON-Body, Fehler als `{ error: 'message' }` +- **Frontend-API-Aufrufe:** `api(method, path, body)` Helper in app.js (fuegt Auth + CSRF automatisch hinzu) +- **Toast-Benachrichtigungen:** `toast('Nachricht')` in app.js +- **Quill-Editor:** `initQuill(selector, placeholder)` — gibt Editor-Instanz zurueck + +## Server starten + +```bash +npm install # Dependencies installieren +npm run dev # Dev-Server mit --watch (Port 8124) +npm start # Produktiv-Server +``` + +Oder: `setup.bat` fuer gefuehrte Installation. + +## Wichtige Dateien nach Groesse + +| Datei | Zeilen | Inhalt | +|---|---|---| +| `public/app.js` | ~3240 | Gesamte Frontend-Logik | +| `server.mjs` | ~1250 | Alle API-Routen + Middleware | +| `public/style.css` | ~1200 | Komplettes Styling + Dark Mode + Responsive | +| `public/index.html` | ~845 | HTML-Struktur (Sidebar, Tabs, Modals, Forms) | +| `lib/db.mjs` | ~390 | Datenbank-Zugriff (alle CRUD-Funktionen) | +| `lib/categories.mjs` | ~360 | Kategorie-Baeume pro Plattform | +| `lib/auth.mjs` | ~290 | Auth-System (Users, Sessions, Audit) | +| `lib/ebay-api.mjs` | ~270 | eBay REST API + OAuth2 | +| `lib/mailer.mjs` | ~210 | SMTP E-Mail-Versand | +| `lib/etsy-api.mjs` | ~185 | Etsy REST API v3 + OAuth2 PKCE | +| `extension/content.js` | ~1170 | Chrome Extension Content Script | + +## Aktueller UI-Stand + +- Version `1.3.0` +- UI 2026 Slice 05: Publish-Center +- Smart Copy, direkte Veröffentlichung, eBay Queue und Scheduling arbeiten in einer gemeinsamen Oberfläche. +- Interne Publish-Arbeitsschritte dürfen keine separaten Browserfenster oder redundanten Modale öffnen. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/FEATURES.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/FEATURES.md new file mode 100644 index 0000000..a259d53 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/FEATURES.md @@ -0,0 +1,707 @@ +## Slice 18 – Mobile Mediathek & OpenRouter Diagnostics + +- Getrennte Kamera- und Mediathek-Auswahl im QR-Upload +- Mobile Mehrfachauswahl sowie HEIC/HEIF-Konvertierung +- Robuster OpenRouter-Request-Fallback pro Modell +- Dynamische Free-Modell-Diagnose und klarer OpenAI-Fallback-Hinweis + +## Slice 17 – LAN QR, OpenRouter Failover & Mobile Navigation + +- Automatische private LAN-IP-Auswahl mit Adapter-Priorisierung +- Konfigurierbare `PUBLIC_BASE_URL` für QR-Upload +- Mehrstufiger OpenRouter-Failover über aktuelle Free-Bildmodelle +- Chat-Completions- und Images-API-Fallback für OpenRouter +- Robuste mobile Off-Canvas-Navigation + +## Slice 16 – AI Job Queue & Multi-Mode Image Generation + +- Queue-basierte AI-Bildgenerierung mit Status-Polling +- 1–3 Varianten statt nur 2–3 +- Modi: Model / Ghost Mannequin / Flatlay +- AI-Bildhistorie im Listing-Editor + +## Slice 15 – AI Provider Settings Center + +- UI für AI-Model-Foto-Provider und Free-Model-Discovery +- Live-Abfrage aktueller OpenRouter-Bildmodelle aus der Settings-Seite +- Konfigurierbare Safety-Flags und Fallback-Logik ohne Codeänderung + +## Slice 14 – OpenRouter Free Image Discovery + +- Dynamic Lookup gegen OpenRouter Models API (`output_modalities=image`) +- Filter auf kostenlose Bildmodelle, die sich laufend ändern können +- Provider-Automatik: OpenRouter Free zuerst, dann OpenAI-Fallback +- Speicherung von `provider` und `provider_model` für spätere Nachvollziehbarkeit + +## Slice 13 – AI-Model-Fotos mit Safety-Filter + +- Sichert den Workflow für Modeartikel ohne Person im Originalbild ab +- Erzeugt 1–3 zusätzliche Bilder als Model, Ghost Mannequin oder Flatlay +- Blockiert Unterwäsche, Bademode, transparente Erotik-Looks und Kinderkleidung +- Speichert Generierungen und erzeugte Bilder in separaten Tabellen für spätere Nachverfolgung + +# Vendoo - Feature-Dokumentation + +> Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. +> Stand: 2026-07-07 | Version 1.5.0 + +## Design-Freeze 2026 + +Die freigegebenen Referenzen in `MockupDesign/` sind die verbindliche UI-/UX-Richtung. Die Startseite wird als **Studio Flow** umgesetzt und bildet den Artikelzyklus `Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` ab. Gemeinsame Design-Tokens, App-Shell, Navigation, Dark Mode und Kontextpanels werden zentral gepflegt. + +--- + +## Tech Stack + +| Komponente | Technologie | +|---|---| +| Backend | Node.js + Express (ESM) | +| Frontend | Vanilla HTML/CSS/JS (kein Framework, kein Build) | +| Datenbank | SQLite via `better-sqlite3` (WAL-Modus) | +| AI | Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) | +| Bildverarbeitung | `sharp` (Resize, Hintergrund, Wasserzeichen) | +| Rich-Text | Quill Editor | +| QR-Codes | QRious | +| E-Mail | Nodemailer (SMTP) | +| Auth | Scrypt-Hashing, Session-basiert, Magic Links | +| Extension | Chrome/Edge Manifest v3, Firefox WebExtension | + +--- + +## Projektstruktur + +``` +Vendoo/ +├── server.mjs # Express-Server (1253 Zeilen, alle API-Routen) +├── package.json # Dependencies + Scripts (start, dev) +├── setup.bat / setup.ps1 # Windows-Installer (Install/Update/Repair/Uninstall) +├── .env / .env.example # Konfiguration (API-Keys, SMTP, Port) +├── db/ +│ ├── schema.sql # DB-Schema (listings, templates, settings, publish_log) +│ └── vendoo.db # SQLite-Datenbank +├── lib/ +│ ├── db.mjs # Datenbank-Zugriff (alle CRUD-Funktionen, ~390 Zeilen) +│ ├── auth.mjs # Auth-System (Users, Sessions, Rate-Limiting, Audit, ~290 Zeilen) +│ ├── ai-router.mjs # AI-Provider-Dispatcher +│ ├── ai-claude.mjs # Anthropic Claude Vision API +│ ├── ai-openai.mjs # OpenAI GPT-4o Vision API +│ ├── ai-openrouter.mjs # OpenRouter Multi-Modell API (~110 Zeilen) +│ ├── ai-local.mjs # Ollama lokale Inferenz +│ ├── images.mjs # Bildverarbeitung (Resize, BG-Remove, Watermark) +│ ├── fees.mjs # Gebührenberechnung pro Plattform +│ ├── categories.mjs # Kategorie-Bäume pro Plattform (~360 Zeilen) +│ ├── html-templates.mjs # Plattform-spezifische HTML-Beschreibungs-Wrapper +│ ├── mailer.mjs # SMTP E-Mail-Versand + Templates (~210 Zeilen) +│ ├── etsy-api.mjs # Etsy REST API v3 + OAuth2 PKCE (~185 Zeilen) +│ └── ebay-api.mjs # eBay REST API + OAuth2 (~270 Zeilen) +├── platforms/ +│ ├── vinted.mjs # Vinted Prompt-Template + Parsing +│ ├── ebay-ka.mjs # eBay Kleinanzeigen +│ ├── ebay-de.mjs # eBay.de +│ └── etsy.mjs # Etsy +├── public/ +│ ├── index.html # Haupt-UI (Single Page, ~845 Zeilen) +│ ├── login.html # Login-Seite (Setup, Magic Link, Passwort) +│ ├── style.css # Komplettes Styling inkl. Dark Mode (~1200 Zeilen) +│ └── app.js # Frontend-Logik (~3240 Zeilen) +├── extension/ +│ ├── manifest.json # Chrome Extension Manifest v3 +│ ├── background.js # Service Worker +│ ├── popup.html/js # Extension-Popup +│ ├── content.js # Content Script für Vinted (~1170 Zeilen) +│ ├── content.css # Injiziertes Styling +│ └── vinted-categories.js # Vinted-Kategorie-Mapping +└── uploads/ # Hochgeladene Produktfotos +``` + +--- + +## Datenbank-Schema + +### Tabelle: `listings` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| platform | TEXT NOT NULL | vinted, ebay-ka, ebay-de, etsy | +| ai_provider | TEXT NOT NULL | claude, openai, openrouter, local | +| title | TEXT | Listing-Titel | +| description | TEXT | Beschreibungstext | +| tags | TEXT (JSON) | Array von Tags/Hashtags | +| photos | TEXT (JSON) | Array von Dateinamen | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| brand | TEXT | Marke | +| category | TEXT | Produktkategorie | +| size | TEXT | Groesse | +| color | TEXT | Farbe | +| condition | TEXT | Zustand | +| price | REAL | Preis in EUR | +| status | TEXT DEFAULT 'active' | active, sold, reserved | +| language | TEXT DEFAULT 'de' | Sprache (de,en,fr,nl,pl,es,it,pt) | +| sku | TEXT | Artikelnummer (VD-0001 Format) | +| storage_location | TEXT | Lagerort (z.B. "Regal A2") | +| deleted_at | TEXT | Soft-Delete Timestamp | +| created_at | TEXT | Erstellungsdatum | +| updated_at | TEXT | Letzte Aenderung | + +### Tabelle: `templates` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| name | TEXT NOT NULL | Vorlagen-Name | +| platform | TEXT | Plattform (null = alle) | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| language | TEXT DEFAULT 'de' | Sprache | +| default_tags | TEXT (JSON) | Standard-Tags | +| created_at | TEXT | Erstellungsdatum | + +### Tabelle: `publish_log` +Tracking aller Veroeffentlichungen (listing_id, platform, status, external_id, external_url, published_at) + +### Tabelle: `ebay_queue` +eBay-Publish-Warteschlange (listing_id, status, ebay_item_id, ebay_url, error, retries) + +### Tabelle: `scheduled_publish` +Zeitgesteuerte Veroeffentlichungen (listing_id, platform, scheduled_at, status, error) + +### Tabelle: `settings` +Key-Value Store (default_platform, default_ai, seller_notes, API-Keys, SMTP-Config) + +### Tabelle: `users` +Benutzerverwaltung (email, name, password_hash, role, active, avatar_color, invited_by) + +### Tabelle: `sessions` +Auth-Sessions (user_id, token_hash, ip, user_agent, expires_at) + +### Tabelle: `auth_tokens` +Magic Links + Einladungen (email, token_hash, type, expires_at, used) + +### Tabelle: `rate_limits`, `audit_log`, `login_history` +Sicherheit und Nachverfolgung + +--- + +## Features im Detail + +### 1. AI-Listing-Generator (Kernfeature) + +**Was:** Fotos hochladen → AI generiert komplettes Listing (Titel, Beschreibung, Tags, Metadaten) + +**Ablauf:** +1. Fotos per Drag & Drop oder Dateiauswahl hochladen (max. 10, je 20MB) +2. Plattform waehlen (Vinted, eBay KA, eBay.de, Etsy) +3. AI-Provider waehlen (Claude, OpenAI, OpenRouter, Ollama) +4. Modell waehlen (bei OpenRouter/Ollama) +5. Sprache waehlen (8 Sprachen) +6. Varianten waehlen (1-3) +7. Optional: Vorlage waehlen (Seller Notes) +8. "Listing generieren" klicken +9. AI analysiert Fotos und liefert: Titel, Beschreibung, Tags, Marke, Kategorie, Groesse, Farbe, Zustand, Preisvorschlag +10. Ergebnis in editierbaren Feldern anzeigen (Quill WYSIWYG-Editor) +11. HTML-Vorlage mit plattform-spezifischem Styling generieren +12. Gebuehrenberechnung automatisch anzeigen +13. Speichern erzeugt Listing mit auto-generierter SKU (VD-XXXX) + +**Dateien:** `server.mjs` (POST /api/generate), `lib/ai-*.mjs`, `platforms/*.mjs`, `public/app.js` + +**API:** `POST /api/generate`, `POST /api/upload`, `GET /api/providers`, `GET /api/platforms` + +--- + +### 2. Foto-Management & Editor + +**Upload:** +- Drag & Drop Dropzone +- Multi-File-Auswahl (nur Bilder: jpg, png, webp, gif) +- Vorschau-Thumbnails mit Entfernen-Button +- Drag-to-Sort (Reihenfolge aendern) + +**Bildbearbeitung (Canvas-basiert):** +- Helligkeit (-100 bis +100) +- Kontrast (-100 bis +100) +- Saettigung (-100 bis +100) +- Drehen (links/rechts 90°) +- Spiegeln (horizontal) +- Zuschneiden (Crop-Tool) +- Zuruecksetzen auf Original +- Uebernehmen/Abbrechen + +**Bildtools:** +- Hintergrund entfernen (Corner-Sample-Methode, Toleranz 60) +- Wasserzeichen hinzufuegen (Text-Overlay, 4% Schriftgroesse, 40% Opazitaet) + +**API:** `POST /api/upload`, `POST /api/images/remove-bg`, `POST /api/images/watermark` + +--- + +### 3. Listing-Verwaltung (Historie) + +**Such- und Filteroptionen:** +- Volltextsuche (Titel, Beschreibung, Marke) mit 300ms Debounce +- Plattform-Filter (Dropdown) +- Status-Filter (Aktiv, Verkauft, Reserviert) +- Datumsbereich-Filter (Von/Bis) + +**Listenansicht:** +- Thumbnail + Titel + SKU-Badge +- Plattform + AI-Provider + Preis + Datum +- Lagerort-Anzeige (wenn zugewiesen) +- Publish-Status-Badges pro Plattform (published/copied/pending) +- Inline-Status-Aenderung (Dropdown) +- Duplizieren-Button +- Loeschen-Button (Soft-Delete → Papierkorb) + +**Batch-Operationen:** +- Alle auswaehlen / Einzelauswahl +- Ausgewaehlte loeschen +- Alle loeschen (gefiltert) +- Status massenweise aendern (Aktiv/Verkauft/Reserviert) +- Etiketten drucken (ausgewaehlte) + +**Pagination:** +- Seitengroesse: 10 / 20 / 50 / Alle +- Vor/Zurueck (oben + unten) +- Seite X von Y, Gesamtanzahl + +**Export:** +- CSV-Export (mit Plattform/Status-Filter) +- JSON-Export (alle gefilterten Listings) + +**Detail-Ansicht:** +- vollständiger Listing-Arbeitsbereich statt reduziertem Detailformular +- Fotogalerie mit großem Cover, Thumbnail-Leiste und vollständiger Bilddarstellung +- Fotos hinzufügen, entfernen, per Drag & Drop sortieren und als Cover festlegen +- vorhandene Bilder direkt mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- QR-Code (SKU + ID, via QRious) +- Alle Felder editierbar: Plattform, AI-Provider, Sprache, SKU, Status, Titel, Tags, Verkäufernotizen, Marke, Kategorie, Groesse, Farbe, Zustand, Preis, Lagerort +- Beschreibung gleichzeitig im Quill-WYSIWYG und im HTML-Code bearbeiten +- WYSIWYG- und HTML-Codeansicht sind live und bidirektional synchronisiert +- separate sandboxed Plattformvorschau +- Editor-HTML und vollständiges Plattform-HTML separat kopierbar +- Aenderungen speichern +- Alles kopieren +- Etikett erstellen +- Datenblatt erstellen +- Loeschen + +**API:** `GET/POST/PUT/DELETE /api/listings`, `POST /api/listings/:id/duplicate`, `GET /api/export/csv` + +--- + +### 4. Vorlagen-System (Templates) + +**3 vordefinierte Vorlagen (Seed):** +1. "Sneaker & Schuhe" (Vinted, DE) — Versand-/Zustandshinweise + Sneaker-Tags +2. "Vintage Kleidung" (Vinted, DE) — Vintage-spezifische Hinweise + Retro-Tags +3. "Elektronik & Zubehoer" (eBay.de, DE) — Funktionshinweise + Technik-Tags + +**Verwaltung:** +- Liste aller Vorlagen (Name, Plattform, Sprache) +- Erstellen: Name, Plattform (optional), Sprache, Seller Notes (Quill), Standard-Tags +- Bearbeiten / Loeschen +- Vorlagen-Auswahl im Generator (Dropdown) +- Bei Auswahl: uebernimmt Plattform + Sprache + Seller Notes + Tags + +**API:** `GET/POST/PUT/DELETE /api/templates` + +--- + +### 5. Publishing-System + +**UI 2026 / Slice 05 – Publish-Center:** +- gemeinsame Arbeitsoberfläche für Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung +- bildorientierte Listing-Auswahl mit Suche, Filtern, Sortierung und Mehrfachauswahl +- inline bearbeitbare Publish-Daten mit Pflichtfeld- und Integrationsprüfung +- Queue-Status, Fehlerwiederholung, Aktivitätsverlauf und Schnellaktionen in einer Statusleiste +- keine separaten Browserfenster für interne Publish-Arbeitsschritte + +**Smart Copy (manuelles Veroeffentlichen):** +- Schritt-fuer-Schritt-Anleitung pro Plattform +- Fotogalerie mit Download +- Kopierbare Felder: Titel, Beschreibung (Text + HTML), Tags, Preis, Metadaten +- "Plattform oeffnen" Button (Direkt-URL) +- "Als veroeffentlicht markieren" Button + +**Direkte API-Veroeffentlichung:** +- Etsy: OAuth2 + PKCE → Listing erstellen + Bilder hochladen +- eBay.de: OAuth2 → Inventory Item + Offer + Publish + +**eBay-Warteschlange:** +- Listings zur Queue hinzufuegen (Batch) +- Queue-Ansicht: SKU, Titel, Status, Fehler +- Alle verarbeiten / Fehlgeschlagene wiederholen / Queue leeren +- Status-Tracking: pending → uploading → published/failed + +**Zeitgesteuerte Veroeffentlichung:** +- Datum + Uhrzeit waehlen +- Plattform waehlen +- Batch-Scheduling moeglich +- Uebersicht aller geplanten Veroeffentlichungen +- Background-Job prüft alle 60 Sekunden auf faellige Items + +**Publish-Status-Tracking:** +- Publish-Log pro Listing + Plattform +- Status-Badges in Historie + Publish-Ansicht + +**API:** `POST /api/publish/smart-copy`, `POST /api/publish/mark`, `POST /api/publish/etsy`, `POST /api/ebay/publish/:id`, `GET/POST/DELETE /api/ebay/queue`, `GET/POST/DELETE /api/schedule` + +--- + +### 6. Lagerverwaltung (Inventory) + +**Uebersicht:** +- Statistik-Karten: Artikel gesamt, Lagerorte, Aktive Artikel, Lagerwert (EUR) +- Lagerort-Kacheln mit: Name, Artikelanzahl (aktiv/verkauft/reserviert), Gesamtwert +- "Ohne Lagerort" Kachel fuer nicht zugewiesene Artikel +- Neuen Lagerort anlegen + +**Detail-Ansicht (pro Lagerort):** +- Artikelliste mit Checkboxen +- SKU, Titel, Plattform, Preis, Status +- Lagerort umbenennen +- Ausgewaehlte Artikel an anderen Ort verschieben (Bulk) + +**Integration:** +- Lagerort-Feld in Listing-Detail editierbar +- Lagerort-Feld im Generator (bei Erstellung setzbar) +- Lagerort in Historie-Listenansicht sichtbar + +**API:** `GET /api/inventory/locations`, `GET /api/inventory/unassigned`, `GET /api/inventory/location/:loc`, `PUT /api/inventory/bulk-location` + +--- + +### 7. SKU / Artikelnummer-System + +- Auto-Generierung: VD-0001, VD-0002, ... (4-stellig, aufsteigend) +- Backfill fuer bestehende Listings ohne SKU +- SKU in Detail-Ansicht editierbar +- SKU in QR-Codes kodiert (JSON: {sku, id}) +- SKU auf Etiketten und Datenblaettern + +--- + +### 8. Gebuehrenrechner + +**Plattform-Gebuehren:** +| Plattform | Provision | Fixkosten | +|---|---|---| +| Vinted | 5% Kaeuferschutz | + 0,70 EUR | +| eBay Kleinanzeigen | 0% | Kostenlos | +| eBay.de | 11% Verkaufsprovision | + 0,35 EUR | +| Etsy | 6,5% + 4% Payment | + 0,30 + 0,20 EUR | + +- Echtzeit-Berechnung bei Preiseingabe +- Zeigt: Brutto, Gebuehren, Netto pro Plattform +- Auch im Generator bei Preisvorschlag + +**API:** `GET /api/fees` + +--- + +### 9. Kategorie-System + +- Hierarchischer Kategorie-Picker (Baum mit Gruppen/Untergruppen) +- Suchfunktion mit Scoring (Name + Pfad) +- Plattform-spezifische Kategorien (Vinted, eBay KA, eBay.de, Etsy) +- Caching fuer Performance +- Ausgewaehlte Kategorie mit Clear-Button + +**API:** `GET /api/categories/:platform`, `GET /api/categories/:platform/match` + +--- + +### 10. Druck-Funktionen + +**Etiketten (Labels):** +- 2-Spalten-Grid, A4-druckfertig +- QR-Code + SKU + Titel (40 Zeichen) + Plattform + Preis +- Batch-Druck fuer mehrere Listings + +**Datenblaetter:** +- Volle Listing-Details auf A4 +- QR-Code + bis zu 4 Fotos +- Beschreibung (200 Zeichen) + Metadaten-Tabelle + +--- + +### 11. Auth & Benutzerverwaltung + +**Authentifizierung:** +- Passwort-Login (Scrypt-Hashing) +- Magic Links per E-Mail (15 Min gueltig) +- Setup-Modus (erster Admin erstellt Konto) +- CSRF-Token-Validierung +- Rate-Limiting (5 Versuche / 15 Min) +- Session-basiert (3 Tage Ablauf, automatische Bereinigung) + +**Benutzerverwaltung (Admin):** +- Benutzer einladen (Magic Link oder Auto-Passwort) +- Rollen: Admin, Editor +- Benutzer aktivieren/deaktivieren +- Passwort zuruecksetzen +- Login-Verlauf einsehen +- Sessions verwalten (eigene + alle) +- Einladung erneut senden + +**Audit-Log:** +- Alle Aktionen protokolliert (Login, CRUD, Settings, etc.) +- Benutzer, Aktion, Ziel, IP, Timestamp +- Paginierte Ansicht + +**API:** `POST /auth/login`, `POST /auth/setup`, `POST /auth/invite`, `GET /api/me`, Admin-Routen unter `/api/admin/*` + +--- + +### 12. E-Mail / SMTP + +- Konfigurierbar ueber Admin-Panel oder .env +- SMTP-Verbindungstest +- Magic-Link-Versand +- Einladungs-E-Mails mit Passwort +- Gebrandetes HTML-Template (Vendoo-Header) +- Fallback: Console-Output wenn SMTP nicht konfiguriert + +**API:** `GET/PUT /api/admin/smtp`, `POST /api/admin/smtp/test` + +--- + +### 13. System & Backup (Admin) + +**System-Info:** +- Node.js Version, Uptime, RAM-Verbrauch +- Datenbank-Groesse +- Benutzer- und Listing-Anzahl +- Aktive Sessions + +**Backup & Restore:** +- DB-Download als .db-Datei +- DB-Restore per Upload (erstellt Backup der alten DB) +- Abgelaufene Sessions bereinigen + +**API:** `GET /api/admin/system`, `GET /api/backup`, `POST /api/restore` + +--- + +### 14. Chrome Extension + +**Zweck:** Automatisches Ausfuellen von Vinted-Listing-Formularen + +**Features:** +- Verbindung zum Vendoo-Server (localhost:8124) +- Queue-Verwaltung: naechstes Listing aus Warteschlange +- Auto-Fill: Titel, Beschreibung, Fotos, Kategorie, Metadaten +- Vinted-Kategorie-Mapping (337 Zeilen) +- Status-Badge (Connected/Offline) +- Batch-Verarbeitung mit konfigurierbarem Delay (Standard 45s) + +**Host Permissions:** localhost:8124, vinted.de/.fr/.co.uk/.com + +--- + +### 15. Einstellungen + +- API-Keys: Anthropic, OpenAI, OpenRouter (maskiert angezeigt) +- Ollama-URL (Standard: localhost:11434) +- Etsy API Key + OAuth-Verbindung +- eBay Client ID/Secret/RuName + OAuth + Sandbox-Toggle +- Standard-Plattform, Standard-AI, Standard-Sprache +- Verkaeuferbemerkungen (global, in jedes Listing eingefuegt) + +**API:** `GET/PUT /api/settings` + +--- + +### 16. UI / Design + +- **Sidebar-Navigation** (links, 230px, fixed) mit 4 Gruppen: Allgemein, Listings, Tools, System +- **Topbar** mit dynamischem Titel, Suchen-Button, "Neues Listing"-Button +- **Dark Mode** (Toggle im Sidebar-Footer, localStorage-Persistenz, System-Praeferenz) +- **Responsive** (Mobile: Sidebar versteckt, Hamburger-Menue, Overlay) +- **Toast-Benachrichtigungen** (2,5s) +- **Browser-Notifications** (Listing gespeichert, eBay Queue, Scheduled) +- **Quill WYSIWYG-Editor** (Toolbar: H1-H3, Bold, Italic, Underline, Strike, Listen, Links, Farben) +- **Live-HTML-Workbench**: WYSIWYG, editierbarer HTML-Code und Plattformvorschau gleichzeitig sichtbar + +--- + +### 17. Installer (setup.bat + setup.ps1) + +**Menue-Optionen:** +1. Installieren (Node.js pruefen/installieren, npm install, .env erstellen, Ordner) +2. Starten (Server + Browser oeffnen) +3. Update (Dependencies aktualisieren, neue .env-Eintraege) +4. Reparieren (node_modules loeschen + neu installieren) +5. Deinstallieren (node_modules + optional Daten entfernen) +6. Status (Node/npm/Dependencies/.env/DB/Uploads pruefen) + +**CLI-Modus:** `setup.bat install`, `setup.bat update`, etc. + +--- + +## API-Endpunkt-Uebersicht + +### Auth +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /auth/setup | Erstes Admin-Konto erstellen | +| POST | /auth/login | Anmelden | +| POST | /auth/logout | Abmelden | +| POST | /auth/invite | Einladung senden | +| POST | /auth/accept-invite | Einladung annehmen | +| GET | /auth/magic | Magic-Link-Redirect | +| GET | /auth/setup-check | Setup-Modus pruefen | +| GET | /auth/etsy | Etsy OAuth starten | +| GET | /auth/etsy/callback | Etsy OAuth Callback | +| GET | /auth/ebay | eBay OAuth starten | +| GET | /auth/ebay/callback | eBay OAuth Callback | + +### Listings +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/listings | Alle Listings (q, platform, status) | +| GET | /api/listings/:id | Einzelnes Listing | +| POST | /api/listings | Listing erstellen | +| PUT | /api/listings/:id | Listing aktualisieren | +| DELETE | /api/listings/:id | Listing loeschen (Soft-Delete) | +| POST | /api/listings/:id/duplicate | Listing duplizieren | + +### Generator & AI +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/generate | AI-Listing generieren | +| POST | /api/upload | Fotos hochladen | +| GET | /api/providers | AI-Provider + Modelle | +| GET | /api/platforms | Marktplatz-Plattformen | +| GET | /api/categories/:platform | Kategorie-Baum | +| GET | /api/categories/:platform/match | Kategorie-Suche | +| GET | /api/price-suggest | Preisvorschlag | + +### Bilder +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/images/remove-bg | Hintergrund entfernen | +| POST | /api/images/watermark | Wasserzeichen hinzufuegen | + +### Templates +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/templates | Alle Vorlagen | +| POST | /api/templates | Vorlage erstellen | +| PUT | /api/templates/:id | Vorlage aktualisieren | +| DELETE | /api/templates/:id | Vorlage loeschen | + +### Publishing +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/publish/info/:id | Publish-Status + Verfuegbarkeit | +| POST | /api/publish/smart-copy | Smart-Copy-Daten | +| POST | /api/publish/mark | Als veroeffentlicht markieren | +| GET | /api/publish/status | Batch-Status-Abfrage | +| POST | /api/publish/etsy | Direkt auf Etsy veroeffentlichen | +| POST | /api/ebay/publish/:id | Direkt auf eBay veroeffentlichen | + +### eBay Queue +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/ebay/queue | Warteschlange anzeigen | +| POST | /api/ebay/queue | Listings hinzufuegen | +| DELETE | /api/ebay/queue/:id | Aus Queue entfernen | +| DELETE | /api/ebay/queue | Queue leeren | +| POST | /api/ebay/queue/process | Alle verarbeiten | +| POST | /api/ebay/queue/retry | Fehlgeschlagene wiederholen | +| GET | /api/ebay/policies | eBay-Richtlinien abrufen | + +### Scheduling +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/schedule | Geplante Veroeffentlichungen | +| POST | /api/schedule | Veroeffentlichung planen | +| DELETE | /api/schedule/:id | Geplante entfernen | + +### Inventory +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/inventory/locations | Lagerorte mit Statistiken | +| GET | /api/inventory/unassigned | Artikel ohne Lagerort | +| GET | /api/inventory/location/:loc | Artikel pro Lagerort | +| PUT | /api/inventory/bulk-location | Bulk-Lagerort-Zuweisung | + +### Papierkorb +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/trash | Geloeschte Listings | +| GET | /api/trash/count | Anzahl im Papierkorb | +| POST | /api/trash/:id/restore | Wiederherstellen | +| DELETE | /api/trash/:id | Endgueltig loeschen | +| DELETE | /api/trash | Papierkorb leeren | + +### Gebuehren & Export +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/fees | Gebuehren berechnen | +| GET | /api/export/csv | CSV-Export | +| GET | /api/dashboard/stats | Dashboard-Statistiken | +| GET | /api/html-templates | HTML-Beschreibungs-Templates | +| POST | /api/html-wrap | Gespeichertes Listing in Plattform-HTML wrappen | +| POST | /api/html-render | Nicht persistierende Live-HTML-Vorschau rendern | + +### Mobiler Foto-Upload +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/mobile-upload-sessions | Zeitlich begrenzte Upload-Sitzung und QR-Link erzeugen | +| GET | /api/mobile-upload-sessions/:id | Upload-Sitzung vom Desktop pollen | +| DELETE | /api/mobile-upload-sessions/:id | Upload-Sitzung schließen | +| GET | /mobile-upload/:token | Responsive Smartphone-Uploadseite | +| GET | /mobile-upload-api/:token | Öffentlichen Sitzungsstatus abrufen | +| POST | /mobile-upload-api/:token/photos | Fotos mit Bearer-Token aus dem QR-Link hochladen | + +### Einstellungen & System +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/settings | Alle Einstellungen | +| PUT | /api/settings | Einstellungen speichern | +| GET | /api/me | Aktueller Benutzer | +| PUT | /api/me | Profil aktualisieren | +| GET | /api/backup | DB-Backup herunterladen | +| POST | /api/restore | DB wiederherstellen | +| GET | /api/etsy/status | Etsy-Verbindungsstatus | +| GET | /api/ebay/status | eBay-Verbindungsstatus | + +### Admin (erfordert Admin-Rolle) +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/admin/users | Alle Benutzer | +| POST | /api/admin/users/invite | Benutzer einladen | +| PUT | /api/admin/users/:id | Benutzer bearbeiten | +| DELETE | /api/admin/users/:id | Benutzer loeschen | +| GET | /api/admin/sessions | Alle Sessions | +| DELETE | /api/admin/sessions/:id | Session beenden | +| GET | /api/admin/audit | Audit-Log | +| GET | /api/admin/system | System-Info | +| GET/PUT | /api/admin/smtp | SMTP-Konfiguration | +| POST | /api/admin/smtp/test | SMTP testen | + +## Slice 07 – Cross-Browser Extensions & Mobile Upload + +- `extensions/chrome`, `extensions/edge` und `extensions/firefox` +- Vendoo Link Icon-Set in vier Browsergrößen +- neues Popup und neues In-Page-Panel im freigegebenen Vendoo-Design +- Plattformvorschau/HTML als Umschalter im Generator und Listing-Editor +- responsive Layouts für Desktop, Notebook, iPad und Smartphone +- QR-Code-Upload vom Smartphone mit 20 Minuten gültiger Upload-Sitzung +- Handy-Fotos erscheinen automatisch im Generator beziehungsweise geöffneten Listing + +## Extension UX 1.4.0 + +- Kompakte Queue-Karten mit Produktbild, Preis, SKU und Status +- Direkte Einzelaktion „Nur ausfüllen“ ohne Entwurf und ohne Veröffentlichung +- Option „Daten + Bilder ausfüllen“ ohne Abschlussaktion +- Explizit getrennte Aktionen für Entwurf und Veröffentlichung +- Gleichstand der Chrome-, Edge- und Firefox-Unterordner + + +## Interaktives Dashboard und Studio Flow V2 + +- eigenständiger Dashboard-Menüpunkt mit realen Kennzahlen und interaktiven SVG/CSS-Diagrammen +- Studio Flow verwendet die verständlichen Zustände Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- mehrere Fotodateien werden automatisch als ZIP heruntergeladen +- Publish-Center verfügt über Retry- und Stale-Render-Schutz beim ersten Laden diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/AdminDashboard.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/AdminDashboard.png new file mode 100644 index 0000000..de4f1c6 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/AdminDashboard.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png new file mode 100644 index 0000000..ba31be4 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Dashboard.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Dashboard.png new file mode 100644 index 0000000..4a388a8 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Dashboard.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Einstellungen.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Einstellungen.png new file mode 100644 index 0000000..7adb09c Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Einstellungen.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ExtensionDesignMockup.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ExtensionDesignMockup.png new file mode 100644 index 0000000..ae1f76b Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/ExtensionDesignMockup.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Heute StudioFlow.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Heute StudioFlow.png new file mode 100644 index 0000000..a3d3c85 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Heute StudioFlow.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/IconLogoMockup.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/IconLogoMockup.png new file mode 100644 index 0000000..d731ebf Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/IconLogoMockup.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Lager.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Lager.png new file mode 100644 index 0000000..891ee35 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Lager.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Listings.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Listings.png new file mode 100644 index 0000000..9e6679b Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Listings.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Publish.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Publish.png new file mode 100644 index 0000000..47aab98 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Publish.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Startseite.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Startseite.png new file mode 100644 index 0000000..77735fa Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Startseite.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Vorlagen.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Vorlagen.png new file mode 100644 index 0000000..2e93bb3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/MockupDesign/Vorlagen.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/README.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/README.md new file mode 100644 index 0000000..940ddd4 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/README.md @@ -0,0 +1,94 @@ +## Neu in 1.12.0 + +Der mobile QR-Upload bietet nun zwei eindeutige Wege: **Kamera öffnen** und **Mediathek auswählen**. Dadurch erzwingt das Smartphone nicht mehr ausschließlich die Kamera. Mehrere vorhandene Bilder können direkt gewählt werden; HEIC/HEIF werden nach Möglichkeit serverseitig zu JPG konvertiert. + +OpenRouter wurde zusätzlich gehärtet: Vendoo erkennt Free-Bildmodelle präziser, prüft Bildinput-Fähigkeit, versucht mehrere kompatible Request-Formen und wechselt bei Fehlern automatisch zum nächsten Modell. Sind aktuell keine kostenlosen Bildmodelle vorhanden, zeigt die Diagnose das ausdrücklich an. + +## Neu in 1.11.0 + +Der QR-Handy-Upload erzeugt jetzt automatisch Links mit der erkannten LAN-IP des Vendoo-PCs. Unter **Einstellungen → Vendoo LAN-Adresse für QR-Upload** kann die Adresse erkannt, geprüft und dauerhaft gespeichert werden. Die mobile Navigation wurde für Smartphone und Tablet komplett stabilisiert. Bei OpenRouter-Bildgenerierung versucht Vendoo automatisch mehrere passende Free-Modelle nacheinander und nutzt im Auto-Modus anschließend OpenAI als Fallback. + +## Neu in 1.10.0 + +Vendoo unterstützt jetzt eine **AI-Job-Queue** für Bildgenerierung. Kleidung kann mit **1 bis 3** Varianten als **Model**, **Ghost Mannequin** oder **Flatlay** generiert werden. Im Listing-Editor gibt es zusätzlich eine **Historie früherer AI-Generierungen**, damit erzeugte Bilder schnell wiederverwendet werden können. + +## Neu in 1.9.0 + +Die Einstellungen wurden um ein eigenes Steuerzentrum für **AI-Model-Fotos** erweitert. Dort lassen sich Provider, Free-Filter, Safety-Blocker, Standard-Preset, Variantenanzahl und das OpenAI-Fallback-Modell pflegen. Zusätzlich kann die UI die aktuell verfügbaren OpenRouter-Bildmodelle direkt laden und anzeigen. + +## Neu in 1.8.0 + +Vendoo kann jetzt neben OpenAI auch OpenRouter für AI-Model-Fotos verwenden. Wenn `ai_model_photo_provider=auto` gesetzt ist und `OPENROUTER_API_KEY` vorhanden ist, fragt Vendoo automatisch aktuelle Bildmodelle von OpenRouter ab, filtert auf Bildausgabe und – sofern aktiviert – nur kostenlose Varianten. Gibt es kein passendes freies Modell mehr, fällt Vendoo sauber auf OpenAI zurück. + +### Neue relevante Settings + +- `ai_model_photo_provider`: `auto`, `openrouter`, `openai` +- `ai_model_photo_openrouter_free_only`: `1` oder `0` +- `ai_model_photo_openrouter_model`: optional feste OpenRouter-Model-ID +- `ai_model_photo_openai_model`: OpenAI-Fallback-Modell, Default `gpt-image-1` + +### Neue ENV-Variablen + +- `OPENROUTER_API_KEY` +- optional `OPENROUTER_SITE_URL` +- optional `OPENROUTER_APP_NAME` + +## Neu in 1.7.0 + +Vendoo kann jetzt für geeignete Kleidungsstücke sichere AI-Model-Fotos erzeugen. Der Workflow prüft zuerst, ob ein Kleidungsartikel ohne Person vorliegt, blockiert sensible Kategorien und erstellt danach 1–3 zusätzliche Lifestyle-/Studio-Bilder mit fiktiven erwachsenen Personen oder alternative Ghost-Mannequin-/Flatlay-Varianten. Die Bilder lassen sich direkt in Generator und Listing-Editor übernehmen. + +# Vendoo + +Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +## Aktueller Entwicklungsstand + +- Version: `1.5.0` +- UI-Slice: `UI 2026 / Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload` +- Startseite: `Studio Flow` +- Tech Stack: Node.js, Express (ESM), Vanilla JS, SQLite (`better-sqlite3`) + +Die vollständige Funktionsbeschreibung steht in [`FEATURES.md`](FEATURES.md). Die freigegebene Designrichtung liegt unter [`MockupDesign/`](MockupDesign/). Der vollständige Listing-Editor ist in [`docs/FULL_LISTING_EDITOR_2026.md`](docs/FULL_LISTING_EDITOR_2026.md) dokumentiert. + +## Lokaler Start + +```bash +npm install +npm start +``` + +Danach ist Vendoo standardmäßig unter `http://localhost:8124` erreichbar. + +## Wichtige Sicherheitsregel + +Nicht versionieren oder weitergeben: + +- `.env` +- `db/vendoo.db*` +- `uploads/` +- Backups und Logs +- API-Schlüssel, OAuth-Tokens und SMTP-Passwörter + +Die bereitgestellte Projektversion enthält ausschließlich `.env.example` und keine produktiven Laufzeitdaten. + +## Browser-Erweiterungen + +Die aktuellen Erweiterungen liegen getrennt unter `extensions/chrome`, `extensions/edge` und `extensions/firefox`. Der frühere Ordner `extension` ist nur noch ein Migrationshinweis. + +## Mobiler Foto-Upload + +Im Generator und im Listing-Editor kann ein zeitlich begrenzter QR-Code erzeugt werden. Nach dem Scan können Fotos direkt vom Smartphone aufgenommen und ohne Neuladen an Vendoo übertragen werden. Für andere Geräte muss Vendoo über eine erreichbare LAN-IP oder `PUBLIC_BASE_URL` geöffnet sein; `localhost` funktioniert nur auf demselben Gerät. + +## Browser-Erweiterung 1.4.0 + +Die Erweiterungen für Chrome, Edge und Firefox verwenden jetzt eine kompakte, aktionsorientierte Oberfläche. Die primäre Aktion **Nur ausfüllen** trägt ein Listing in das geöffnete Vinted-Formular ein, ohne einen Entwurf zu speichern und ohne eine Veröffentlichung auszulösen. Abschlussaktionen sind davon deutlich getrennt. + + +## Aktueller UI-Stand + +Slice 10 ergänzt das interaktive Dashboard, den aufgeräumten Studio Flow, robuste Publish-Initialisierung, ZIP-Fotodownloads und erweiterte Responsive-Gates. + + +## Branding + +Logo, App-Icons, mobile Größen und Favicons liegen unter `public/brand/`. Die Browser-Erweiterungen für Chrome, Edge und Firefox verwenden dieselbe Icon-Familie. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/SECURITY.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/SECURITY.md new file mode 100644 index 0000000..aa107a4 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/SECURITY.md @@ -0,0 +1,19 @@ +# Sicherheit + +## Vertrauliche Dateien + +Folgende Dateien dürfen niemals in Git oder öffentliche Pakete gelangen: + +- `.env` +- `db/*.db`, `db/*.db-wal`, `db/*.db-shm` +- `uploads/` +- Backups, Logs und Session-Daten +- private Schlüssel und Zertifikate + +## Online-Betrieb + +Vendoo darf nicht durch eine direkte Portfreigabe von Port 8124 veröffentlicht werden. Für öffentliche Nutzer ist eine getrennte Server-/VPS-Instanz hinter HTTPS und Reverse Proxy vorgesehen. + +## Meldung von Sicherheitsproblemen + +Sicherheitsprobleme nicht mit Zugangsdaten oder personenbezogenen Daten in öffentliche Issues schreiben. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/db/schema.sql b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/db/schema.sql new file mode 100644 index 0000000..49e8139 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/db/schema.sql @@ -0,0 +1,111 @@ +CREATE TABLE IF NOT EXISTS listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + ai_provider TEXT NOT NULL, + title TEXT, + description TEXT, + description_html TEXT, + tags TEXT, + photos TEXT, + seller_notes TEXT, + brand TEXT, + category TEXT, + size TEXT, + color TEXT, + condition TEXT, + price REAL, + status TEXT DEFAULT 'active', + language TEXT DEFAULT 'de', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + platform TEXT, + seller_notes TEXT, + language TEXT DEFAULT 'de', + default_tags TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS publish_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + status TEXT DEFAULT 'pending', + external_id TEXT, + external_url TEXT, + published_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_platform', 'vinted'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_ai', 'claude'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('seller_notes', ''); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_language', 'de'); + +CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at); + + +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photos_enabled', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_variants', '3'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_preset', 'mixed'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_underwear', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_kids', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_provider', 'openai'); + +CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id); + +CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (generation_id) REFERENCES ai_model_generations(id) ON DELETE CASCADE, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/BRANDING.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/BRANDING.md new file mode 100644 index 0000000..2aafd18 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/BRANDING.md @@ -0,0 +1,23 @@ +# Vendoo Branding + +Die Anwendung verwendet ab Version 1.6.2 ein gemeinsames Vendoo-Branding für Web-App, Login, mobilen Foto-Upload und Browser-Erweiterungen. + +## Web-App + +Assets liegen unter `public/brand/`: + +- `logo/` – horizontales Logo, transparente und zugeschnittene Varianten +- `icons/` – App- und Mobilgrößen von 16 bis 512 Pixel +- `favicons/` – ICO, PNG-Favicons, Apple Touch Icon, Android-Icons und Webmanifest + +## Browser-Erweiterungen + +Chrome, Edge und Firefox verwenden dieselbe Icon-Familie in 16, 32, 48 und 128 Pixeln. Popup, Browser-Toolbar, Vinted Assistant und minimierter Assistant verwenden das neue Markenzeichen. + +## Farben + +- Vendoo Orange: `#E63B17` +- Graphit: `#22201D` +- Warmes Off-White: `#FAF6F2` + +Die Proportionen und Farben der bereitgestellten Assets sollen nicht manuell verändert werden. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/FULL_LISTING_EDITOR_2026.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/FULL_LISTING_EDITOR_2026.md new file mode 100644 index 0000000..f9cea38 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/FULL_LISTING_EDITOR_2026.md @@ -0,0 +1,29 @@ +# Vendoo Full Listing Editor 2026 + +## Ziel + +Bereits gespeicherte Artikel werden nicht mehr in einem reduzierten Detailformular, sondern in einem vollständigen Arbeitsbereich bearbeitet. Generator und Listing-Editor verwenden dieselbe Rich-Text- und HTML-Logik. + +## Funktionsumfang + +- vollständige Bearbeitung aller Listing-Felder +- Plattform, AI-Provider, Sprache, Status, SKU und Lagerort +- Titel, Tags, Verkäufernotizen und sämtliche erkannten Attribute +- Preis und Kategorie +- Foto hinzufügen, entfernen, sortieren und als Cover festlegen +- vorhandene Fotos mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- WYSIWYG und HTML-Code gleichzeitig sichtbar +- Änderungen im WYSIWYG erscheinen sofort im HTML-Code +- Änderungen im HTML-Code werden in den WYSIWYG-Editor zurückgeführt +- separate sandboxed Plattformvorschau +- Editor-HTML und Plattform-HTML separat kopierbar + +## Datenmodell + +`listings.description_html` speichert das bereinigte Rich-Text-HTML. `listings.description` bleibt als Klartext-Fallback und für Plattformen beziehungsweise Exporte erhalten. + +Die neue Spalte wird über das bestehende `MIGRATIONS`-Array in `lib/db.mjs` ergänzt. + +## Sicherheit + +HTML wird auf unterstützte Formatierungs-Tags reduziert. Skripte, eingebettete Frames, Formulare, Event-Handler und gefährliche URLs werden entfernt. Vorschauen werden in einem `sandbox`-iframe dargestellt. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/GENERATOR_STUDIO_2026.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/GENERATOR_STUDIO_2026.md new file mode 100644 index 0000000..720bc55 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/GENERATOR_STUDIO_2026.md @@ -0,0 +1,61 @@ +# Vendoo Listing Studio 2026 + +## Ziel + +Der Listing-Generator ist kein Formularstapel mehr, sondern ein zusammenhängender Studio-Arbeitsplatz mit drei klaren Zonen: + +1. Fotos & Medien +2. AI-Konfiguration +3. Vorschau & Bearbeitung + +Die bestehende Vanilla-JS-, Express- und SQLite-Architektur bleibt erhalten. + +## Fotos & Medien + +- großes Cover mit vollständiger Bilddarstellung über `object-fit: contain` +- Klick auf ein Vorschaubild setzt es als Cover +- Drag & Drop sortiert die Bildreihenfolge +- maximal zehn Bilder pro Listing +- sichtbarer Foto-Check mit Qualitätsindikatoren +- bestehender Bildeditor, Hintergrundentfernung und Wasserzeichen bleiben integriert + +## AI-Konfiguration + +- Plattform, Provider und Modell +- Sprache und Anzahl der Varianten +- Vorlagenauswahl +- Verkäufernotizen mit Zeichenzähler +- Vorlagen übernehmen Plattform, Sprache, Hinweise und Standard-Tags +- global hinterlegte Verkäuferhinweise, Vorlagenhinweise und Artikelhinweise werden kontrolliert zusammengeführt + +## Vorschau & Bearbeitung + +- permanenter, kontextbezogener Ergebnisbereich +- Live-Zähler für Titel, Beschreibung und Tags +- editierbare erkannte Attribute +- Kategorie-Picker, Preis und Lagerort +- dynamische Gebührenvorschau +- nachvollziehbarer Qualitätswert +- HTML- und Plattformvorschau als optional aufklappbarer Bereich +- Speichern und Kopieren ohne Fensterwechsel + +## API-Erweiterung + +`POST /api/generate` akzeptiert zusätzlich: + +```json +{ + "template_id": 1, + "seller_notes": "Artikelspezifische Hinweise" +} +``` + +Standard-Tags der gewählten Vorlage werden dedupliziert mit den AI-Tags zusammengeführt. + +## Qualitätsregeln + +- keine neuen Frameworks +- keine neuen Datenbankspalten +- bestehende IDs und Generatorfunktionen bleiben kompatibel +- alle Farben und Zustände nutzen die Vendoo-CSS-Variablen +- Dark Mode und responsive Darstellung sind Bestandteil des Slices diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/HOTFIX_STUDIO_FLOW_DETAIL.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/HOTFIX_STUDIO_FLOW_DETAIL.md new file mode 100644 index 0000000..6194ec5 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/HOTFIX_STUDIO_FLOW_DETAIL.md @@ -0,0 +1,16 @@ +# Hotfix – Studio Flow Detailnavigation + +## Fehler + +Der Button **Prüfen & bearbeiten** lud die Listing-Detailansicht, ohne zuvor den Listings-Tab sichtbar zu schalten. Der Editor wurde dadurch in einem ausgeblendeten Bereich aufgebaut. + +## Korrektur + +`showDetail(id)` prüft nun zentral, ob der Listings-/Historie-Tab aktiv ist. Falls nicht, wird dieser vor dem Laden des Listings aktiviert. Damit funktionieren Detailaufrufe aus: + +- Studio Flow +- Attention Queue +- Listings-Tabelle +- weiteren internen Bereichen + +Zusätzlich werden ungültige Listing-IDs abgefangen. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/LISTINGS_UI_2026.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/LISTINGS_UI_2026.md new file mode 100644 index 0000000..8d3528c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/LISTINGS_UI_2026.md @@ -0,0 +1,31 @@ +# Vendoo Listings UI 2026 + +## Ziel + +Die Listings-/Historie-Seite bildet die operative Übersicht aller Artikel. Sie folgt der freigegebenen Vendoo-Designsprache und ersetzt die alte Kartenliste durch eine dichte, bildorientierte Arbeitsliste. + +## Funktionsumfang + +- Volltextsuche über bestehende Listing-Suche +- Plattform- und Statusfilter +- Zeiträume 7, 30 und 90 Tage, aktuelles Jahr, gesamter Zeitraum und benutzerdefinierte Daten +- Spaltenkonfiguration mit Speicherung im Browser +- Seitengrößen 10, 20, 50 oder alle +- Seitennavigation mit direkten Seitenschaltflächen +- Auswahl einzelner Listings und Auswahl der aktuellen Seite +- Batch Publish, Nachbearbeiten, Preisänderung, Statusänderung, Duplizieren, Etikettendruck und Löschen +- CSV- und JSON-Export +- Direktänderung des Status pro Tabellenzeile +- Zeilenaktionen Öffnen, Duplizieren und Löschen +- Publish-Status-Badges je Plattform + +## Technische Regeln + +Die Umsetzung bleibt vollständig in der bestehenden Vanilla-Architektur: + +- Markup: `public/index.html` +- Verhalten: `public/app.js` +- Design: `public/style.css` +- keine neuen Frameworks +- keine Änderungen am Datenbankschema erforderlich +- vorhandene API-Endpunkte und CSRF-Absicherung werden weiterverwendet diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/PUBLISH_CENTER_2026.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/PUBLISH_CENTER_2026.md new file mode 100644 index 0000000..10dece3 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/PUBLISH_CENTER_2026.md @@ -0,0 +1,28 @@ +# Vendoo Publish-Center 2026 + +## Ziel + +Das Publish-Center bündelt alle Arbeitsschritte zwischen fertigem Listing und Veröffentlichung in einer einzigen, kontextstabilen Oberfläche. + +## Bereiche + +- **Smart Copy:** Felder, HTML/Text, Fotos und Plattformwechsel +- **Direkt veröffentlichen:** Etsy- und eBay-API mit vorheriger Datenprüfung +- **eBay Queue:** Status, Fehler, Wiederholungen und Verarbeitung +- **Geplant:** zeitgesteuerte Veröffentlichungen mit Plattform und Zeitpunkt + +## UX-Regeln + +- keine internen Arbeitsabläufe in neuen Browserfenstern +- Produktbild, Titel und SKU bleiben im Kontext sichtbar +- Fehler und fehlende Pflichtfelder sind direkt handlungsfähig +- externe Plattformen öffnen nur dann einen neuen Tab, wenn die Plattform selbst benötigt wird +- alle Statusfarben verwenden die zentralen Vendoo-Tokens + +## Technische Grundlage + +- Vanilla JavaScript in `public/app.js` +- Markup in `public/index.html` +- Design-Tokens und responsive Komponenten in `public/style.css` +- bestehende Endpunkte für Smart Copy, Etsy, eBay Queue und Scheduling +- Session-, Rollen- und CSRF-Schutz bleiben unverändert aktiv diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_02_ACCEPTANCE.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_02_ACCEPTANCE.md new file mode 100644 index 0000000..124fac5 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_02_ACCEPTANCE.md @@ -0,0 +1,29 @@ +# Slice 02 – Listings / Historie Acceptance + +## Abnahmeziele + +- [x] freigegebene Vendoo-Designsprache übernommen +- [x] bestehende Listings-API unverändert weiterverwendet +- [x] Suche, Plattform-, Status- und Datumsfilter erhalten +- [x] Zeitraumauswahl ergänzt +- [x] CSV- und JSON-Export erhalten +- [x] Einzel- und Seitenauswahl umgesetzt +- [x] Batch Status, Preis, Duplizieren, Etiketten, Publish und Löschen umgesetzt +- [x] Inline-Statusänderung pro Listing erhalten +- [x] Detailansicht weiterhin erreichbar +- [x] Pagination mit direkter Seitenauswahl umgesetzt +- [x] Spaltenkonfiguration lokal gespeichert +- [x] Dark-Mode-Tokens verwendet +- [x] keine hardcodierten produktiven Secrets enthalten + +## Technische Prüfungen + +- `node --check public/app.js` +- `node --check server.mjs` +- `node --check lib/db.mjs` +- keine doppelten HTML-IDs +- ausgeglichene CSS-Klammerstruktur + +## Runtime-Hinweis + +Ein vollständiger Runtime-Test benötigt die plattformspezifische Installation von `better-sqlite3`. Das Paket enthält bewusst kein `node_modules`; Installation erfolgt über den gehärteten Installer beziehungsweise `npm ci`. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_05_ACCEPTANCE.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_05_ACCEPTANCE.md new file mode 100644 index 0000000..80aca05 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_05_ACCEPTANCE.md @@ -0,0 +1,28 @@ +# Slice 05 – Abnahme Publish-Center + +## Funktionsumfang + +- [x] Smart Copy vollständig innerhalb Vendoo +- [x] direkte Etsy-/eBay-Veröffentlichung mit Integrationsstatus +- [x] Listing-Daten vor der Veröffentlichung inline bearbeitbar +- [x] eBay Queue ohne separates Modal steuerbar +- [x] Fehler, Wiederholungen und Queue-Status sichtbar +- [x] geplante Veröffentlichungen inline verwaltbar +- [x] Batch-Auswahl aus Listings wird übernommen +- [x] Fotos werden vollständig ohne Beschnitt dargestellt +- [x] responsive Layouts für Desktop, Tablet und Mobile +- [x] Dark Mode ausschließlich über zentrale CSS-Variablen + +## Technische Prüfungen + +- [x] `node --check server.mjs` +- [x] `node --check lib/db.mjs` +- [x] `node --check public/app.js` +- [x] keine doppelten HTML-IDs +- [x] ausgeglichene CSS-Klammerstruktur +- [x] keine `.env`, Datenbank, Uploads oder `node_modules` im Paket +- [x] bestehende API-Routen, Authentifizierung und CSRF-Logik beibehalten + +## Installation + +Als Update über `setup.bat` → **3 Update** installieren. Danach Browser mit `Strg + F5` neu laden. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_06_ACCEPTANCE.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_06_ACCEPTANCE.md new file mode 100644 index 0000000..c63ebce --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_06_ACCEPTANCE.md @@ -0,0 +1,25 @@ +# Slice 06 – Abnahme + +## Funktionsabnahme + +- [x] Studio Flow öffnet den sichtbaren Listing-Editor. +- [x] gespeicherte Listings können vollständig bearbeitet werden. +- [x] Fotos können ergänzt, entfernt, sortiert und als Cover gesetzt werden. +- [x] der vorhandene Fotoeditor funktioniert im Generator und im Listing-Editor. +- [x] WYSIWYG-Änderungen erscheinen unmittelbar im HTML-Code. +- [x] HTML-Codeänderungen werden in den WYSIWYG-Editor zurückgeführt. +- [x] Generator und Listing-Editor rendern eine separate Plattformvorschau. +- [x] Rich-Text-HTML wird dauerhaft in `description_html` gespeichert. +- [x] Klartext bleibt in `description` als Fallback erhalten. +- [x] Plattformvorschauen laufen in sandboxed iframes. + +## Technische Abnahme + +- [x] DB-Migration für `description_html` im `MIGRATIONS`-Array. +- [x] `photos` und `tags` werden bei Updates als JSON gespeichert. +- [x] Listing-Updates verwenden eine feste Feld-Allowlist. +- [x] Client- und Server-Sanitizing für Rich-Text-HTML. +- [x] JavaScript-/ESM-Syntaxprüfung bestanden. +- [x] keine doppelten statischen HTML-IDs. +- [x] CSS-Klammerstruktur vollständig. +- [x] keine `.env`, Datenbanken, Uploads oder `node_modules` im Paket. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md new file mode 100644 index 0000000..939a13b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md @@ -0,0 +1,36 @@ +# Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload + +## Browser-Erweiterungen + +- Chrome: `extensions/chrome` +- Edge: `extensions/edge` +- Firefox: `extensions/firefox` +- neues Vendoo-Link-Icon in 16/32/48/128 px +- neues Popup nach freigegebenem Mockup +- neuer Vendoo Assistant mit produktzentrierter Detailansicht und Auto-Fill-Flow + +## HTML-/Plattformvorschau + +Im Generator und Listing-Editor ist HTML nicht mehr als dauerhaft zweite Spalte sichtbar. Innerhalb der Plattformvorschau kann zwischen **Plattformvorschau** und **HTML-Code** umgeschaltet werden. WYSIWYG und HTML bleiben bidirektional synchronisiert. + +## Responsive Layout + +Abnahmepunkte: + +- Desktop ab 1440 px +- Notebook 1180–1439 px +- iPad Querformat 1024 px +- Tablet Hochformat 768 px +- Smartphone 360–560 px + +Navigation, Generator, Listing-Editor, Studio Flow, Tabellen, Modale und Kontextbereiche werden je Breakpoint neu priorisiert. + +## Mobiler Foto-Upload + +1. Nutzer erzeugt in Generator oder Listing-Editor einen QR-Code. +2. Vendoo erstellt ein kryptografisch zufälliges, gehasht gespeichertes Upload-Token. +3. Der Link ist 20 Minuten gültig und auf maximal zehn Bilder begrenzt. +4. Smartphone lädt Bilder über eine eigenständige responsive Upload-Seite hoch. +5. Desktop pollt die Sitzung und übernimmt neue Bilder automatisch. + +`PUBLIC_BASE_URL` oder eine erreichbare LAN-/HTTPS-Adresse ist erforderlich, wenn der QR-Code von einem anderen Gerät geöffnet wird. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md new file mode 100644 index 0000000..3ed520e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md @@ -0,0 +1,33 @@ +# Slice 08 – Extension Auto-Fill & UX + +## Ziel + +Die Vinted-Erweiterung soll kompakt, hochwertig und sicher bedienbar sein. Das bloße Ausfüllen eines Formulars darf niemals automatisch einen Entwurf erzeugen oder eine Veröffentlichung auslösen. + +## Verbindliches Aktionsmodell + +1. **Nur ausfüllen** + - trägt Titel, Beschreibung, Preis, Tags und bestmöglich weitere Attribute ein + - wählt die Kategorie, sofern möglich + - speichert keinen Entwurf + - veröffentlicht nicht + - setzt keinen Publish-Status in Vendoo + +2. **Daten + Bilder ausfüllen** + - überträgt zusätzlich die Produktbilder + - speichert keinen Entwurf + - veröffentlicht nicht + +3. **Als Entwurf** und **Veröffentlichen** + - sind getrennte, explizite Abschlussaktionen + - befinden sich im Detailbereich unter einer aufklappbaren Sicherheitssektion + +## Browser + +Identische UI- und Funktionsbasis unter: + +- `extensions/chrome` +- `extensions/edge` +- `extensions/firefox` + +Version: `1.4.0` diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md new file mode 100644 index 0000000..5e5be6a --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md @@ -0,0 +1,17 @@ +# Slice 11 – Dashboard-First & Responsive Hotfix + +## Umfang + +- Dashboard als erster Navigationspunkt und Standardansicht +- fließende KPI-Kacheln mit Auto-Fit +- responsive Sparklines und Diagramme +- ResizeObserver für Dashboard-Visualisierungen +- vollständige Produktbild-Darstellung im Studio Flow +- automatische Kartenverteilung für Desktop, Tablet und Mobile + +## Abnahme + +- keine Text-/Diagrammüberlagerung in KPI-Karten +- keine abgeschnittenen Bilder in „Bereit für den nächsten Schritt“ +- Dashboard öffnet nach dem Login zuerst +- Diagramme reagieren auf Fenster- und Sidebar-Breitenänderungen diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_FOUNDATION_2026.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_FOUNDATION_2026.md new file mode 100644 index 0000000..427fb32 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_FOUNDATION_2026.md @@ -0,0 +1,41 @@ +# Vendoo UI Foundation 2026 + +## Status + +Die Designrichtung ist durch die freigegebenen Referenzen in `MockupDesign/` verbindlich festgelegt. + +## Leitidee + +Vendoo ist eine **Commerce Workbench**, kein generisches Statistik-Dashboard. + +Der zentrale Arbeitsfluss lautet: + +`Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` + +## Verbindliche Gestaltungsregeln + +- warme Off-White-Grundflächen und Graphit-Typografie +- Vendoo-Orange nur als gezielter Aktions- und Fokusakzent +- Produktbilder bilden die wichtigste visuelle Ebene +- Statusfarben transportieren Bedeutung statt Dekoration +- schmale Hauptnavigation und kontextuelle Detailflächen +- keine austauschbaren KPI-Kartenlandschaften +- keine violett-blauen Standardverläufe +- keine dekorativen Charts ohne operative Funktion +- wenige, bewusst eingesetzte Rundungen und Schatten +- Dark Mode wird eigenständig über CSS Custom Properties gestaltet + +## Slice 01 + +Dieser Slice führt ein: + +- neue App-Shell mit kompakter Navigation und Command Search +- zentrale Design-Tokens für Light und Dark Mode +- neue Startseite `Studio Flow` +- Workflow-Dashboard-API unter `/api/dashboard/workflow` +- Attention Queue, Activity Stream und Artikel-Inspector +- unveränderte Vanilla-JavaScript-/Express-/SQLite-Architektur + +## Noch nicht Teil dieses Slices + +Die vollständige visuelle Umstellung von Listings, Generator, Publish, Lager, Vorlagen, Einstellungen und Admin erfolgt in nachfolgenden isolierten Slices auf derselben Foundation. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_UX_FIXES_SLICE_03.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_UX_FIXES_SLICE_03.md new file mode 100644 index 0000000..a987762 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/UI_UX_FIXES_SLICE_03.md @@ -0,0 +1,26 @@ +# Vendoo UI 2026 – Slice 03 + +## Umfang + +### Print Studio + +Etiketten und Datenblätter werden innerhalb der Vendoo-Oberfläche vorbereitet. Das Print Studio bietet eine A4-Vorschau, Etikettenformate 90 × 50 mm und 62 × 29 mm, mehrere Exemplare sowie eine optionale Preisanzeige. Erst der abschließende Druckbefehl öffnet den normalen Systemdruckdialog. Es wird kein zusätzliches Browserfenster erzeugt. + +### Studio Flow Media Fit + +Produktbilder in Flow-Karten, Inspector und Attention Queue werden vollständig dargestellt. Die Medienflächen verwenden `object-fit: contain`, zentrierte Ausrichtung und neutrale Produktflächen. + +### Vinted Extension + +Die Extension verwendet die Vendoo-Designsprache mit warmen Flächen, Graphittypografie und Vendoo-Orange. Produktbilder werden nicht mehr direkt als HTTP-Bild in die Vinted-Seite eingebettet, sondern durch den Background-Service-Worker geladen und als Data-URL eingesetzt. Das vermeidet Blockaden durch Seiten-CSP oder gemischte Inhalte. + +## Geänderte Dateien + +- `public/app.js` +- `public/style.css` +- `extension/background.js` +- `extension/content.js` +- `extension/content.css` +- `extension/popup.html` +- `extension/popup.js` +- `extension/manifest.json` diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/superpowers/specs/2026-07-06-autolister-features-design.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/superpowers/specs/2026-07-06-autolister-features-design.md new file mode 100644 index 0000000..5e7f7f7 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/docs/superpowers/specs/2026-07-06-autolister-features-design.md @@ -0,0 +1,66 @@ +# AutoLister Feature-Erweiterungen + +## 1. Kategorie-Fixes + +### a) Auto-Matching nach AI-Generierung +- `showMeta()` ruft `/api/categories/:platform/match?q=` auf +- Bestes Ergebnis wird im Picker vorausgewählt +- Freitext-Fallback wenn kein Match (Score < 50) + +### b) Plattform-Wechsel aktualisiert Picker +- Plattform-Button-Click prüft ob Meta-Section sichtbar ist +- Wenn ja: Kategorie-Picker wird mit neuer Plattform neu initialisiert +- Bisheriger Wert wird als Suchquery gegen neue Plattform gematcht + +### c) Extension nutzt Server-API +- `content.js` lädt Kategorien via `/api/categories/vinted` statt lokaler Datei +- `vinted-categories.js` wird aus manifest.json entfernt (Datei bleibt als Fallback) +- Match-Funktion nutzt `/api/categories/vinted/match?q=` + +## 2. WYSIWYG ↔ HTML Sync + +- Quill `text-change` Event → HTML-Source-Textarea wird aktualisiert (`editor.root.innerHTML`) +- HTML-Source `input` Event → Quill übernimmt via `editor.root.innerHTML = textarea.value` +- Toolbar-Icons bekommen `title`-Attribute: Bold→"Fett", Italic→"Kursiv", Underline→"Unterstrichen", Strike→"Durchgestrichen", List Ordered→"Nummerierte Liste", List Bullet→"Aufzählung", Blockquote→"Zitat", Link→"Link einfügen", Clean→"Formatierung entfernen", Color→"Textfarbe", Background→"Hintergrundfarbe", Align→"Ausrichtung", Header→"Überschrift" + +## 3. Veröffentlichungsstatus in Historie + +- `loadHistory()` ruft zusätzlich `/api/publish/status?ids=` auf +- Pro Listing in der Liste: farbige Badges für jede Plattform + - Grün: veröffentlicht + - Orange: kopiert + - Kein Badge: nicht veröffentlicht +- Badges erscheinen neben dem Status-Badge in der History-Liste + +## 4. SKU/Artikelnummer-System + +### DB +- Neues Feld: `sku TEXT` in listings-Tabelle +- Auto-generiert: `AL-XXXX` (fortlaufend, 4-stellig zero-padded) +- Jederzeit editierbar auf eigene Bezeichnung + +### UI-Integration +- Generator Meta-Section: SKU-Feld (readonly, auto-generiert beim Erstellen) +- Historie-Liste: SKU als erste Info nach Thumbnail +- Detail-View: editierbares SKU-Feld +- Publish-Liste: SKU angezeigt + +### QR-Code +- Client-seitig via qrcode.js (CDN) +- QR-Inhalt: `{"sku":"AL-0001","id":42,"url":"http://localhost:8124/#listing/42"}` +- Angezeigt im Detail-View + +### Druck-Formate +**Kompakt-Etikett (~90×50mm):** +- QR links, rechts: SKU, Titel (max 40 Zeichen), Preis, Plattform +- 4 Etiketten pro A4-Seite bei Batch-Druck + +**Produkt-Datenblatt (A4):** +- Header: QR + SKU groß +- Foto-Thumbnails (max 4) +- Titel, Beschreibung (max 200 Zeichen), Marke, Kategorie, Größe, Farbe, Zustand, Preis +- Plattform, Erstelldatum + +### Druck-Buttons +- Detail-View: "Etikett drucken" + "Datenblatt drucken" +- Historie Batch-Bar: "Etiketten drucken" (mehrere auf einem Blatt) diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/README.txt b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/README.txt new file mode 100644 index 0000000..88490e1 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/README.txt @@ -0,0 +1,8 @@ +VERALTETER ORDNER + +Die Browser-Erweiterungen befinden sich ab Version 1.3.0 unter: +- extensions/chrome +- extensions/edge +- extensions/firefox + +Bitte die bisher geladene Erweiterung entfernen und den passenden neuen Unterordner laden. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/background.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.css new file mode 100644 index 0000000..305e62a --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.css @@ -0,0 +1,455 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.js new file mode 100644 index 0000000..e46e7f1 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/content.js @@ -0,0 +1,1359 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                          + + + ... + + + + +
                          +
                          + + + +
                          +
                          + + + 0 ausgewählt +
                          +
                          +
                          +
                          +
                          Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                          +
                          0offen
                          +
                          +
                            +
                            +
                            +
                              +
                              +
                              +
                              +
                              +
                              Batch-Modus
                              + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                              +
                              +
                              Pause zwischen Artikeln (Sek.)
                              + +
                              +
                              +
                              Server URL
                              + +
                              +
                              + +
                              +
                              + +
                              +
                              +
                              +
                              +
                              +
                              + +
                              + `; + 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 = ``; 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 = '
                            • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                            • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                            • +
                              + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                              +
                              ${statusHtml}Vinted
                              +
                              ${esc(l.title || 'Ohne Titel')}
                              +
                              ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                              +
                              ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                              +
                              +
                              ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                              +
                              + + +
                              +
                            • `; + }).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) await fillListingData(listing, { includePhotos: false }); + })); + 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 = '
                            • 📦Noch keine Artikel veröffentlicht.
                            • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                            • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                              +
                              ${esc(l.title || 'Ohne Titel')}
                              +
                              ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                              +
                              + Erledigt +
                            • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + `; + 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-detail').addEventListener('click', () => showDetail(id)); + actions.querySelector('#al-selection-more').addEventListener('click', () => actions.querySelector('#al-selection-menu').classList.toggle('hidden')); + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                              + + +
                              + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                              + Bereit +

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

                              + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                              Kategorie
                              ${esc(listing.category || 'Nicht gesetzt')}
                              Marke
                              ${esc(listing.brand || '–')}
                              Größe
                              ${esc(listing.size || '–')}
                              Zustand
                              ${esc(listing.condition || '–')}
                              +
                              +
                              +
                              ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                              + +
                              +

                              Veröffentlichen auf Vinted

                              +
                              +
                              1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                              2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                              3. 3Beschreibung übernehmenBeschreibung und Attribute
                              4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                              5. 5VeröffentlichungListing abschließen
                              +
                              Auto-Fill · Vinted

                              Vendoo übernimmt alle vorhandenen Listing-Daten.

                              • ✓ Titel
                              • ✓ Preis
                              • ✓ Kategorie
                              • ✓ Größe & Marke
                              • ✓ Beschreibung
                              • ✓ Bilder
                              ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                              +
                              +
                              + +
                              Listing-Daten prüfen & bearbeiten +
                              +
                              Titel
                              + +
                              +
                              +
                              Beschreibung
                              + +
                              +
                              +
                              +
                              Preis
                              + +
                              +
                              +
                              Zustand
                              + +
                              +
                              +
                              +
                              +
                              Marke
                              + +
                              +
                              +
                              Größe
                              + +
                              +
                              +
                              +
                              Kategorie (Vinted)
                              +
                              + +
                              +
                              +
                              +
                              +
                              +
                              +
                              Farbe
                              + +
                              +
                              +
                              AI-Kategorie (Original)
                              + +
                              +
                              +
                              +
                              Tags
                              + +
                              + +
                              + + +
                              + + +

                              Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                              +
                              + +
                              + Entwurf speichern oder veröffentlichen +
                              + + + + +
                              +
                              + +
                              + `; + + 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 = String(value).trim().toLowerCase(); + if (!normalized) return false; + let trigger = null; + for (const label of document.querySelectorAll('label')) { + if (label.closest('#al-panel')) continue; + const text = label.textContent.trim().toLowerCase(); + if (labels.some(item => text.includes(item.toLowerCase()))) { + trigger = label.parentElement?.querySelector('button,[role="button"],select,input') || (label.htmlFor ? document.getElementById(label.htmlFor) : null); + if (trigger) break; + } + } + if (!trigger) { + for (const el of document.querySelectorAll('button,[role="button"]')) { + if (el.closest('#al-panel')) continue; + const text = el.textContent.trim().toLowerCase(); + if (labels.some(item => text === item.toLowerCase() || text.startsWith(item.toLowerCase()))) { trigger = el; break; } + } + } + if (!trigger) return false; + if (trigger.tagName === 'INPUT') { 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 = '
                              Keine Kategorie gefunden
                              '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                              +
                              ${esc(m.name)}
                              +
                              ${esc(m.path.slice(0, -1).join(' > '))}
                              +
                              + `).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 = '
                              '; + for (const main of VINTED_CATEGORIES) { + html += `
                              `; + html += `
                              ${esc(main.name)}
                              `; + html += `
                              `; + if (main.children) { + for (const sub of main.children) { + html += `
                              `; + html += `
                              ${esc(sub.name)}
                              `; + html += `
                              `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                              ${esc(leaf.name)}
                              `; + } + } + html += '
                              '; + } + } + html += '
                              '; + } + html += '
                              '; + 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 = ` +
                              +
                              +
                              ${esc(cat.name)}
                              +
                              ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                              +
                              + +
                              + `; + 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 findField(selectors) { + for (const s of selectors) { const el = document.querySelector(s); if (el) return el; } + return null; + } + + function findByLabel(text, tag) { + for (const label of document.querySelectorAll('label')) { + if (label.textContent.toLowerCase().includes(text.toLowerCase())) { + const el = label.querySelector(tag) || label.parentElement?.querySelector(tag); + if (el) return el; + if (label.htmlFor) return document.getElementById(label.htmlFor); + } + } + return null; + } + + function fillVintedForm(fields) { + let filled = 0; + + const titleEl = findField(['[data-testid="title-input"] input', 'input[name="title"]', '[class*="Title"] input']) + || 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 = ` +
                              +
                              + Modus: ${modeText} +
                              +
                              +
                              +
                              + 0 / ${STATE.batchProgress.total} + 0% +
                              +
                              + +
                              +
                              + `; + 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 += `
                              • ${esc(title.substring(0, 30))}... ${step}
                              `; + 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 += `
                              ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                              `; + 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(); + const pending = await storageGet(['pendingFillListingId']); + if (pending.pendingFillListingId) { + const listing = STATE.queue.find(item => item.id === Number(pending.pendingFillListingId)); + if (listing) { + await storageSet({ pendingFillListingId: null }); + await fillListingData(listing, { includePhotos: false }); + showDetail(listing.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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon-48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon.svg b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/manifest.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/manifest.json new file mode 100644 index 0000000..dc27b67 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.css new file mode 100644 index 0000000..909cf8c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.css @@ -0,0 +1 @@ +:root{--bg:#f4f0e9;--surface:#fffdf9;--surface-2:#f8f5ef;--line:#ddd6cb;--ink:#22201d;--muted:#7b746b;--brand:#e63b17;--brand-dark:#c92f0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#bd4339}*{box-sizing:border-box}body{width:420px;min-height:610px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.popup-shell{min-height:610px;display:grid;grid-template-rows:auto 1fr auto;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.055),transparent 38%),var(--bg)}.popup-header{height:68px;display:flex;align-items:center;gap:10px;padding:0 17px;border-bottom:1px solid var(--line);background:rgba(255,253,249,.97)}.brand-lockup{display:flex;align-items:center;gap:10px;min-width:0}.brand-lockup img{width:36px;height:36px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:15px;letter-spacing:-.035em}.brand-lockup span{font-size:9px;color:var(--muted)}.connection-chip{margin-left:auto;min-height:25px;display:flex;align-items:center;gap:6px;padding:0 9px;border-radius:999px;background:#eee9e1;color:var(--muted);font-size:9px;font-weight:800}.connection-chip i{width:7px;height:7px;border-radius:50%;background:#aaa}.connection-chip.ok{background:var(--success-soft);color:var(--success)}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:rgba(189,67,57,.1);color:var(--danger)}.connection-chip.error i{background:var(--danger)}.icon-button{width:32px;height:32px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--muted);font-size:15px;cursor:pointer}.icon-button:hover{border-color:var(--line);background:var(--surface-2);color:var(--ink)}.popup-content,.settings-view{padding:15px 17px 18px;overflow:auto}.queue-card,.listing-card,.empty-card{border:1px solid var(--line);border-radius:15px;background:var(--surface);box-shadow:0 10px 28px rgba(42,36,29,.055)}.queue-card{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:11px;align-items:center;padding:14px}.queue-icon{width:36px;height:36px;display:grid;place-items:center;border-radius:11px;background:var(--brand-soft);color:var(--brand);font-size:17px;font-weight:900}.queue-card span{display:block;color:var(--brand);font-size:8px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}.queue-card strong{display:block;margin-top:3px;font-size:12px}.queue-card small{display:block;margin-top:2px;color:var(--muted);font-size:8px}.queue-progress{height:5px;margin-top:9px;overflow:hidden;border-radius:999px;background:#e9e3da}.queue-progress i{display:block;width:0;height:100%;border-radius:inherit;background:var(--brand);transition:width .25s ease}.queue-count{display:grid;justify-items:end;gap:7px}.queue-count b{font-size:11px}.queue-count button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.listing-card{margin-top:12px;padding:12px}.listing-card>header{display:flex;align-items:center;justify-content:space-between;padding:2px 2px 11px}.listing-card>header>div{display:flex;align-items:center;gap:7px}.listing-card>header span{font-size:11px;font-weight:850}.listing-card>header b{padding:3px 7px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:8px}.listing-card>header button{border:0;background:transparent;color:var(--muted);font-size:14px}.listing-overview{display:grid;grid-template-columns:128px minmax(0,1fr);gap:12px}.listing-photo{height:166px;display:grid;place-items:center;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#fff,#eee9e1)}.listing-photo img{width:100%;height:100%;object-fit:contain;padding:7px}.listing-copy{min-width:0}.listing-copy h2{margin:2px 0 5px;font-size:13px;line-height:1.32;letter-spacing:-.025em}.listing-copy p{margin:0;color:var(--muted);font-size:9px}.listing-copy code{display:block;margin:7px 0 11px;color:var(--muted);font-size:8px}.listing-copy dl{display:grid;grid-template-columns:62px minmax(0,1fr);gap:7px 5px;margin:0;font-size:9px}.listing-copy dt{color:var(--muted)}.listing-copy dd{margin:0;font-weight:700}.platform-chip{display:inline-flex;padding:2px 6px;border-radius:6px;background:#eee9e1;font-size:8px}.safe-notice{display:grid;gap:2px;margin:12px 0 3px;padding:10px 11px;border:1px solid rgba(33,131,86,.18);border-radius:10px;background:var(--success-soft)}.safe-notice b{color:var(--success);font-size:9px}.safe-notice span{color:var(--muted);font-size:8px;line-height:1.4}.primary-action,.secondary-action{min-height:42px;border-radius:10px;font:inherit;font-size:10px;font-weight:850;cursor:pointer}.primary-action{width:100%;display:flex;justify-content:center;align-items:center;gap:8px;border:0;background:var(--brand);color:#fff}.primary-action:hover{background:var(--brand-dark)}.primary-action span{margin-left:auto;padding-right:5px}.action-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.secondary-action{width:100%;border:1px solid var(--line);background:var(--surface);color:var(--ink)}.secondary-action:hover{border-color:var(--brand);color:var(--brand)}.empty-card{padding:38px 22px;text-align:center}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:10px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:10px;font-weight:850}.settings-view{display:grid;align-content:start;gap:12px}.settings-heading{display:flex;align-items:center;gap:9px;margin-bottom:3px}.settings-heading button{width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:9px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:9px;font-weight:800}.setting input,.setting select{width:100%;height:42px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}footer{min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 17px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:8px}footer button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.hidden{display:none!important} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.html new file mode 100644 index 0000000..9b23a67 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.html @@ -0,0 +1,49 @@ + + + + + + Vendoo Link + + + +
                              + + + + + + +
                              Vendoo Link v1.6.0
                              +
                              + + + \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.js new file mode 100644 index 0000000..8fbde6b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/popup.js @@ -0,0 +1,129 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], current: 0, serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatDate(value) { + const date = new Date(String(value || '').replace(' ', 'T') + 'Z'); + return Number.isNaN(date.getTime()) ? '–' : date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +async function renderCurrent() { + const listing = state.queue[state.current]; + $('#listing-card').classList.toggle('hidden', !listing); + $('#empty-card').classList.toggle('hidden', !!listing); + if (!listing) return; + $('#listing-title').textContent = listing.title || 'Ohne Titel'; + $('#listing-meta').textContent = [listing.size, listing.color, listing.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften'; + $('#listing-sku').textContent = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + $('#listing-price').textContent = listing.price ? `${Number(listing.price).toFixed(2).replace('.', ',')} €` : '–'; + $('#listing-created').textContent = formatDate(listing.created_at); + const src = await imageData(listing.photos?.[0]); + const image = $('#listing-image'); + if (src) { image.src = src; image.classList.remove('hidden'); } else { image.removeAttribute('src'); image.classList.add('hidden'); } +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + state.current = Math.min(state.current, Math.max(0, state.queue.length - 1)); + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zur Übertragung' : 'Queue leer'; + $('#queue-count').textContent = `${state.queue.length} / ${data.total || state.queue.length}`; + $('#queue-progress-fill').style.width = `${data.total ? ((data.total - state.queue.length) / data.total) * 100 : 0}%`; + await renderCurrent(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = error.message; + $('#listing-card').classList.add('hidden'); + $('#empty-card').classList.remove('hidden'); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +function openUrl(url) { openTab(url); } +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#next-listing').addEventListener('click', async () => { if (state.queue.length) state.current = (state.current + 1) % state.queue.length; await renderCurrent(); }); +$('#fill-listing').addEventListener('click', async () => { + const listing = state.queue[state.current]; + if (!listing) return; + await storageSet({ pendingFillListingId: listing.id }); + openUrl('https://www.vinted.de/items/new'); + window.close(); +}); + +$('#prepare-images').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-queue').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openUrl(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openUrl(state.serverUrl)); + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/vinted-categories.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extension/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/README.md b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/README.md new file mode 100644 index 0000000..c6686d9 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/README.md @@ -0,0 +1,26 @@ +# Vendoo Browser Extensions + +- `chrome/` – Chrome, Manifest V3 +- `edge/` – Microsoft Edge, Manifest V3 +- `firefox/` – Firefox, WebExtension-Paket + +## Lokal laden + +### Chrome +`chrome://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/chrome` auswählen. + +### Edge +`edge://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/edge` auswählen. + +### Firefox +`about:debugging#/runtime/this-firefox` öffnen → **Temporäres Add-on laden** → `extensions/firefox/manifest.json` auswählen. + +Die Serveradresse kann im Popup geändert werden. Für öffentliche Server muss die Vendoo-Adresse per HTTPS erreichbar sein. + +## Serverzugriff + +Die Erweiterung kann mit `localhost`, einer LAN-Adresse oder einer öffentlichen HTTPS-Adresse verbunden werden. Da die Serveradresse frei konfigurierbar ist, verlangen die Browser beim Laden Zugriff auf HTTP-/HTTPS-Seiten. Die Erweiterung verwendet diesen Zugriff ausschließlich für die eingetragene Vendoo-Instanz und die unterstützten Vinted-Seiten. + +## Alte Erweiterung ersetzen + +Der frühere Ordner `extension/` wird nicht mehr verwendet. Eine bereits geladene Altversion zuerst entfernen und danach den passenden Ordner unter `extensions/` laden. diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/background.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/background.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.js new file mode 100644 index 0000000..ecc290b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/content.js @@ -0,0 +1,1369 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                              + + + ... + + + + +
                              +
                              + + + +
                              +
                              + + + 0 ausgewählt +
                              +
                              +
                              +
                              +
                              Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                              +
                              0offen
                              +
                              +
                                +
                                +
                                +
                                  +
                                  +
                                  +
                                  +
                                  +
                                  Batch-Modus
                                  + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                  +
                                  +
                                  Pause zwischen Artikeln (Sek.)
                                  + +
                                  +
                                  +
                                  Server URL
                                  + +
                                  +
                                  + +
                                  +
                                  + +
                                  +
                                  +
                                  +
                                  +
                                  +
                                  + +
                                  + `; + 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 = ``; 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 = '
                                • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                • +
                                  + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                  +
                                  ${statusHtml}Vinted
                                  +
                                  ${esc(l.title || 'Ohne Titel')}
                                  +
                                  ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                  +
                                  ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                  +
                                  +
                                  ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                  +
                                  + + + +
                                  +
                                • `; + }).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 = '
                                • 📦Noch keine Artikel veröffentlicht.
                                • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                  +
                                  ${esc(l.title || 'Ohne Titel')}
                                  +
                                  ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                  +
                                  + Erledigt +
                                • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                  + + +
                                  + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                  + Bereit +

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

                                  + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                  Kategorie
                                  ${esc(listing.category || 'Nicht gesetzt')}
                                  Marke
                                  ${esc(listing.brand || '–')}
                                  Größe
                                  ${esc(listing.size || '–')}
                                  Zustand
                                  ${esc(listing.condition || '–')}
                                  +
                                  +
                                  +
                                  ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                  + +
                                  +

                                  Veröffentlichen auf Vinted

                                  +
                                  +
                                  1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                  2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                  3. 3Beschreibung übernehmenBeschreibung und Attribute
                                  4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                  5. 5VeröffentlichungListing abschließen
                                  +
                                  Auto-Fill · Vinted

                                  Vendoo übernimmt alle vorhandenen Listing-Daten.

                                  • ✓ Titel
                                  • ✓ Preis
                                  • ✓ Kategorie
                                  • ✓ Größe & Marke
                                  • ✓ Beschreibung
                                  • ✓ Bilder
                                  ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                  +
                                  +
                                  + +
                                  Listing-Daten prüfen & bearbeiten +
                                  +
                                  Titel
                                  + +
                                  +
                                  +
                                  Beschreibung
                                  + +
                                  +
                                  +
                                  +
                                  Preis
                                  + +
                                  +
                                  +
                                  Zustand
                                  + +
                                  +
                                  +
                                  +
                                  +
                                  Marke
                                  + +
                                  +
                                  +
                                  Größe
                                  + +
                                  +
                                  +
                                  +
                                  Kategorie (Vinted)
                                  +
                                  + +
                                  +
                                  +
                                  +
                                  +
                                  +
                                  +
                                  Farbe
                                  + +
                                  +
                                  +
                                  AI-Kategorie (Original)
                                  + +
                                  +
                                  +
                                  +
                                  Tags
                                  + +
                                  + +
                                  + + +
                                  + + +

                                  Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                  +
                                  + +
                                  + Entwurf speichern oder veröffentlichen +
                                  + + + + +
                                  +
                                  + +
                                  + `; + + 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 = String(value).trim().toLowerCase(); + if (!normalized) return false; + let trigger = null; + for (const label of document.querySelectorAll('label')) { + if (label.closest('#al-panel')) continue; + const text = label.textContent.trim().toLowerCase(); + if (labels.some(item => text.includes(item.toLowerCase()))) { + trigger = label.parentElement?.querySelector('button,[role="button"],select,input') || (label.htmlFor ? document.getElementById(label.htmlFor) : null); + if (trigger) break; + } + } + if (!trigger) { + for (const el of document.querySelectorAll('button,[role="button"]')) { + if (el.closest('#al-panel')) continue; + const text = el.textContent.trim().toLowerCase(); + if (labels.some(item => text === item.toLowerCase() || text.startsWith(item.toLowerCase()))) { trigger = el; break; } + } + } + if (!trigger) return false; + if (trigger.tagName === 'INPUT') { 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 = '
                                  Keine Kategorie gefunden
                                  '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                  +
                                  ${esc(m.name)}
                                  +
                                  ${esc(m.path.slice(0, -1).join(' > '))}
                                  +
                                  + `).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 = '
                                  '; + for (const main of VINTED_CATEGORIES) { + html += `
                                  `; + html += `
                                  ${esc(main.name)}
                                  `; + html += `
                                  `; + if (main.children) { + for (const sub of main.children) { + html += `
                                  `; + html += `
                                  ${esc(sub.name)}
                                  `; + html += `
                                  `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                  ${esc(leaf.name)}
                                  `; + } + } + html += '
                                  '; + } + } + html += '
                                  '; + } + html += '
                                  '; + 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 = ` +
                                  +
                                  +
                                  ${esc(cat.name)}
                                  +
                                  ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                  +
                                  + +
                                  + `; + 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 findField(selectors) { + for (const s of selectors) { const el = document.querySelector(s); if (el) return el; } + return null; + } + + function findByLabel(text, tag) { + for (const label of document.querySelectorAll('label')) { + if (label.textContent.toLowerCase().includes(text.toLowerCase())) { + const el = label.querySelector(tag) || label.parentElement?.querySelector(tag); + if (el) return el; + if (label.htmlFor) return document.getElementById(label.htmlFor); + } + } + return null; + } + + function fillVintedForm(fields) { + let filled = 0; + + const titleEl = findField(['[data-testid="title-input"] input', 'input[name="title"]', '[class*="Title"] input']) + || 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 = ` +
                                  +
                                  + Modus: ${modeText} +
                                  +
                                  +
                                  +
                                  + 0 / ${STATE.batchProgress.total} + 0% +
                                  +
                                  + +
                                  +
                                  + `; + 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 += `
                                  • ${esc(title.substring(0, 30))}... ${step}
                                  `; + 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 += `
                                  ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                  `; + 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(); + 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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon-48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon.svg b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/manifest.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/manifest.json new file mode 100644 index 0000000..dc27b67 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.html new file mode 100644 index 0000000..1a7b8b2 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                  + + + + + + +
                                  + Vendoo Link v1.6.0 + Nur ausfüllen = kein Entwurf +
                                  +
                                  + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.js new file mode 100644 index 0000000..f51e29b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/popup.js @@ -0,0 +1,178 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                  +
                                  ${escapeHtml(item.title || 'Produktbild')}
                                  +
                                  +
                                  +
                                  ${escapeHtml(item.title || 'Ohne Titel')}
                                  +
                                  ${formatPrice(item.price)}
                                  +
                                  +
                                  ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                  +
                                  ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                  +
                                  + + + + +
                                  +
                                  +
                                  + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/vinted-categories.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/chrome/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.js new file mode 100644 index 0000000..ecc290b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/content.js @@ -0,0 +1,1369 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                                  + + + ... + + + + +
                                  +
                                  + + + +
                                  +
                                  + + + 0 ausgewählt +
                                  +
                                  +
                                  +
                                  +
                                  Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                  +
                                  0offen
                                  +
                                  +
                                    +
                                    +
                                    +
                                      +
                                      +
                                      +
                                      +
                                      +
                                      Batch-Modus
                                      + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                      +
                                      +
                                      Pause zwischen Artikeln (Sek.)
                                      + +
                                      +
                                      +
                                      Server URL
                                      + +
                                      +
                                      + +
                                      +
                                      + +
                                      +
                                      +
                                      +
                                      +
                                      +
                                      + +
                                      + `; + 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 = ``; 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 = '
                                    • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                    • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                    • +
                                      + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                      +
                                      ${statusHtml}Vinted
                                      +
                                      ${esc(l.title || 'Ohne Titel')}
                                      +
                                      ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                      +
                                      ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                      +
                                      +
                                      ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                      +
                                      + + + +
                                      +
                                    • `; + }).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 = '
                                    • 📦Noch keine Artikel veröffentlicht.
                                    • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                    • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                      +
                                      ${esc(l.title || 'Ohne Titel')}
                                      +
                                      ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                      +
                                      + Erledigt +
                                    • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                      + + +
                                      + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                      + Bereit +

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

                                      + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                      Kategorie
                                      ${esc(listing.category || 'Nicht gesetzt')}
                                      Marke
                                      ${esc(listing.brand || '–')}
                                      Größe
                                      ${esc(listing.size || '–')}
                                      Zustand
                                      ${esc(listing.condition || '–')}
                                      +
                                      +
                                      +
                                      ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                      + +
                                      +

                                      Veröffentlichen auf Vinted

                                      +
                                      +
                                      1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                      2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                      3. 3Beschreibung übernehmenBeschreibung und Attribute
                                      4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                      5. 5VeröffentlichungListing abschließen
                                      +
                                      Auto-Fill · Vinted

                                      Vendoo übernimmt alle vorhandenen Listing-Daten.

                                      • ✓ Titel
                                      • ✓ Preis
                                      • ✓ Kategorie
                                      • ✓ Größe & Marke
                                      • ✓ Beschreibung
                                      • ✓ Bilder
                                      ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                      +
                                      +
                                      + +
                                      Listing-Daten prüfen & bearbeiten +
                                      +
                                      Titel
                                      + +
                                      +
                                      +
                                      Beschreibung
                                      + +
                                      +
                                      +
                                      +
                                      Preis
                                      + +
                                      +
                                      +
                                      Zustand
                                      + +
                                      +
                                      +
                                      +
                                      +
                                      Marke
                                      + +
                                      +
                                      +
                                      Größe
                                      + +
                                      +
                                      +
                                      +
                                      Kategorie (Vinted)
                                      +
                                      + +
                                      +
                                      +
                                      +
                                      +
                                      +
                                      +
                                      Farbe
                                      + +
                                      +
                                      +
                                      AI-Kategorie (Original)
                                      + +
                                      +
                                      +
                                      +
                                      Tags
                                      + +
                                      + +
                                      + + +
                                      + + +

                                      Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                      +
                                      + +
                                      + Entwurf speichern oder veröffentlichen +
                                      + + + + +
                                      +
                                      + +
                                      + `; + + 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 = String(value).trim().toLowerCase(); + if (!normalized) return false; + let trigger = null; + for (const label of document.querySelectorAll('label')) { + if (label.closest('#al-panel')) continue; + const text = label.textContent.trim().toLowerCase(); + if (labels.some(item => text.includes(item.toLowerCase()))) { + trigger = label.parentElement?.querySelector('button,[role="button"],select,input') || (label.htmlFor ? document.getElementById(label.htmlFor) : null); + if (trigger) break; + } + } + if (!trigger) { + for (const el of document.querySelectorAll('button,[role="button"]')) { + if (el.closest('#al-panel')) continue; + const text = el.textContent.trim().toLowerCase(); + if (labels.some(item => text === item.toLowerCase() || text.startsWith(item.toLowerCase()))) { trigger = el; break; } + } + } + if (!trigger) return false; + if (trigger.tagName === 'INPUT') { 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 = '
                                      Keine Kategorie gefunden
                                      '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                      +
                                      ${esc(m.name)}
                                      +
                                      ${esc(m.path.slice(0, -1).join(' > '))}
                                      +
                                      + `).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 = '
                                      '; + for (const main of VINTED_CATEGORIES) { + html += `
                                      `; + html += `
                                      ${esc(main.name)}
                                      `; + html += `
                                      `; + if (main.children) { + for (const sub of main.children) { + html += `
                                      `; + html += `
                                      ${esc(sub.name)}
                                      `; + html += `
                                      `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                      ${esc(leaf.name)}
                                      `; + } + } + html += '
                                      '; + } + } + html += '
                                      '; + } + html += '
                                      '; + 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 = ` +
                                      +
                                      +
                                      ${esc(cat.name)}
                                      +
                                      ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                      +
                                      + +
                                      + `; + 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 findField(selectors) { + for (const s of selectors) { const el = document.querySelector(s); if (el) return el; } + return null; + } + + function findByLabel(text, tag) { + for (const label of document.querySelectorAll('label')) { + if (label.textContent.toLowerCase().includes(text.toLowerCase())) { + const el = label.querySelector(tag) || label.parentElement?.querySelector(tag); + if (el) return el; + if (label.htmlFor) return document.getElementById(label.htmlFor); + } + } + return null; + } + + function fillVintedForm(fields) { + let filled = 0; + + const titleEl = findField(['[data-testid="title-input"] input', 'input[name="title"]', '[class*="Title"] input']) + || 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 = ` +
                                      +
                                      + Modus: ${modeText} +
                                      +
                                      +
                                      +
                                      + 0 / ${STATE.batchProgress.total} + 0% +
                                      +
                                      + +
                                      +
                                      + `; + 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 += `
                                      • ${esc(title.substring(0, 30))}... ${step}
                                      `; + 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 += `
                                      ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                      `; + 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(); + 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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/background.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.js new file mode 100644 index 0000000..ecc290b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/content.js @@ -0,0 +1,1369 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                                      + + + ... + + + + +
                                      +
                                      + + + +
                                      +
                                      + + + 0 ausgewählt +
                                      +
                                      +
                                      +
                                      +
                                      Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                      +
                                      0offen
                                      +
                                      +
                                        +
                                        +
                                        +
                                          +
                                          +
                                          +
                                          +
                                          +
                                          Batch-Modus
                                          + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                          +
                                          +
                                          Pause zwischen Artikeln (Sek.)
                                          + +
                                          +
                                          +
                                          Server URL
                                          + +
                                          +
                                          + +
                                          +
                                          + +
                                          +
                                          +
                                          +
                                          +
                                          +
                                          + +
                                          + `; + 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 = ``; 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 = '
                                        • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                        • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                        • +
                                          + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                          +
                                          ${statusHtml}Vinted
                                          +
                                          ${esc(l.title || 'Ohne Titel')}
                                          +
                                          ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                          +
                                          ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                          +
                                          +
                                          ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                          +
                                          + + + +
                                          +
                                        • `; + }).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 = '
                                        • 📦Noch keine Artikel veröffentlicht.
                                        • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                        • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                          +
                                          ${esc(l.title || 'Ohne Titel')}
                                          +
                                          ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                          +
                                          + Erledigt +
                                        • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                          + + +
                                          + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                          + Bereit +

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

                                          + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                          Kategorie
                                          ${esc(listing.category || 'Nicht gesetzt')}
                                          Marke
                                          ${esc(listing.brand || '–')}
                                          Größe
                                          ${esc(listing.size || '–')}
                                          Zustand
                                          ${esc(listing.condition || '–')}
                                          +
                                          +
                                          +
                                          ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                          + +
                                          +

                                          Veröffentlichen auf Vinted

                                          +
                                          +
                                          1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                          2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                          3. 3Beschreibung übernehmenBeschreibung und Attribute
                                          4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                          5. 5VeröffentlichungListing abschließen
                                          +
                                          Auto-Fill · Vinted

                                          Vendoo übernimmt alle vorhandenen Listing-Daten.

                                          • ✓ Titel
                                          • ✓ Preis
                                          • ✓ Kategorie
                                          • ✓ Größe & Marke
                                          • ✓ Beschreibung
                                          • ✓ Bilder
                                          ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                          +
                                          +
                                          + +
                                          Listing-Daten prüfen & bearbeiten +
                                          +
                                          Titel
                                          + +
                                          +
                                          +
                                          Beschreibung
                                          + +
                                          +
                                          +
                                          +
                                          Preis
                                          + +
                                          +
                                          +
                                          Zustand
                                          + +
                                          +
                                          +
                                          +
                                          +
                                          Marke
                                          + +
                                          +
                                          +
                                          Größe
                                          + +
                                          +
                                          +
                                          +
                                          Kategorie (Vinted)
                                          +
                                          + +
                                          +
                                          +
                                          +
                                          +
                                          +
                                          +
                                          Farbe
                                          + +
                                          +
                                          +
                                          AI-Kategorie (Original)
                                          + +
                                          +
                                          +
                                          +
                                          Tags
                                          + +
                                          + +
                                          + + +
                                          + + +

                                          Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                          +
                                          + +
                                          + Entwurf speichern oder veröffentlichen +
                                          + + + + +
                                          +
                                          + +
                                          + `; + + 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 = String(value).trim().toLowerCase(); + if (!normalized) return false; + let trigger = null; + for (const label of document.querySelectorAll('label')) { + if (label.closest('#al-panel')) continue; + const text = label.textContent.trim().toLowerCase(); + if (labels.some(item => text.includes(item.toLowerCase()))) { + trigger = label.parentElement?.querySelector('button,[role="button"],select,input') || (label.htmlFor ? document.getElementById(label.htmlFor) : null); + if (trigger) break; + } + } + if (!trigger) { + for (const el of document.querySelectorAll('button,[role="button"]')) { + if (el.closest('#al-panel')) continue; + const text = el.textContent.trim().toLowerCase(); + if (labels.some(item => text === item.toLowerCase() || text.startsWith(item.toLowerCase()))) { trigger = el; break; } + } + } + if (!trigger) return false; + if (trigger.tagName === 'INPUT') { 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 = '
                                          Keine Kategorie gefunden
                                          '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                          +
                                          ${esc(m.name)}
                                          +
                                          ${esc(m.path.slice(0, -1).join(' > '))}
                                          +
                                          + `).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 = '
                                          '; + for (const main of VINTED_CATEGORIES) { + html += `
                                          `; + html += `
                                          ${esc(main.name)}
                                          `; + html += `
                                          `; + if (main.children) { + for (const sub of main.children) { + html += `
                                          `; + html += `
                                          ${esc(sub.name)}
                                          `; + html += `
                                          `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                          ${esc(leaf.name)}
                                          `; + } + } + html += '
                                          '; + } + } + html += '
                                          '; + } + html += '
                                          '; + 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 = ` +
                                          +
                                          +
                                          ${esc(cat.name)}
                                          +
                                          ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                          +
                                          + +
                                          + `; + 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 findField(selectors) { + for (const s of selectors) { const el = document.querySelector(s); if (el) return el; } + return null; + } + + function findByLabel(text, tag) { + for (const label of document.querySelectorAll('label')) { + if (label.textContent.toLowerCase().includes(text.toLowerCase())) { + const el = label.querySelector(tag) || label.parentElement?.querySelector(tag); + if (el) return el; + if (label.htmlFor) return document.getElementById(label.htmlFor); + } + } + return null; + } + + function fillVintedForm(fields) { + let filled = 0; + + const titleEl = findField(['[data-testid="title-input"] input', 'input[name="title"]', '[class*="Title"] input']) + || 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 = ` +
                                          +
                                          + Modus: ${modeText} +
                                          +
                                          +
                                          +
                                          + 0 / ${STATE.batchProgress.total} + 0% +
                                          +
                                          + +
                                          +
                                          + `; + 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 += `
                                          • ${esc(title.substring(0, 30))}... ${step}
                                          `; + 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 += `
                                          ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                          `; + 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(); + 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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon-48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon.svg b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/manifest.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/manifest.json new file mode 100644 index 0000000..dc27b67 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.html new file mode 100644 index 0000000..1a7b8b2 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                          + + + + + + +
                                          + Vendoo Link v1.6.0 + Nur ausfüllen = kein Entwurf +
                                          +
                                          + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.js new file mode 100644 index 0000000..f51e29b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/popup.js @@ -0,0 +1,178 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                          +
                                          ${escapeHtml(item.title || 'Produktbild')}
                                          +
                                          +
                                          +
                                          ${escapeHtml(item.title || 'Ohne Titel')}
                                          +
                                          ${formatPrice(item.price)}
                                          +
                                          +
                                          ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                          +
                                          ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                          +
                                          + + + + +
                                          +
                                          +
                                          + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/vinted-categories.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/edge/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/background.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.js new file mode 100644 index 0000000..ecc290b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/content.js @@ -0,0 +1,1369 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                                          + + + ... + + + + +
                                          +
                                          + + + +
                                          +
                                          + + + 0 ausgewählt +
                                          +
                                          +
                                          +
                                          +
                                          Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                          +
                                          0offen
                                          +
                                          +
                                            +
                                            +
                                            +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              Batch-Modus
                                              + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                              +
                                              +
                                              Pause zwischen Artikeln (Sek.)
                                              + +
                                              +
                                              +
                                              Server URL
                                              + +
                                              +
                                              + +
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              + +
                                              + `; + 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 = ``; 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 = '
                                            • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                            • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                            • +
                                              + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                              +
                                              ${statusHtml}Vinted
                                              +
                                              ${esc(l.title || 'Ohne Titel')}
                                              +
                                              ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                              +
                                              ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                              +
                                              +
                                              ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                              +
                                              + + + +
                                              +
                                            • `; + }).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 = '
                                            • 📦Noch keine Artikel veröffentlicht.
                                            • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                            • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                              +
                                              ${esc(l.title || 'Ohne Titel')}
                                              +
                                              ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                              +
                                              + Erledigt +
                                            • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                              + + +
                                              + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                              + Bereit +

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

                                              + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                              Kategorie
                                              ${esc(listing.category || 'Nicht gesetzt')}
                                              Marke
                                              ${esc(listing.brand || '–')}
                                              Größe
                                              ${esc(listing.size || '–')}
                                              Zustand
                                              ${esc(listing.condition || '–')}
                                              +
                                              +
                                              +
                                              ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                              + +
                                              +

                                              Veröffentlichen auf Vinted

                                              +
                                              +
                                              1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                              2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                              3. 3Beschreibung übernehmenBeschreibung und Attribute
                                              4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                              5. 5VeröffentlichungListing abschließen
                                              +
                                              Auto-Fill · Vinted

                                              Vendoo übernimmt alle vorhandenen Listing-Daten.

                                              • ✓ Titel
                                              • ✓ Preis
                                              • ✓ Kategorie
                                              • ✓ Größe & Marke
                                              • ✓ Beschreibung
                                              • ✓ Bilder
                                              ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                              +
                                              +
                                              + +
                                              Listing-Daten prüfen & bearbeiten +
                                              +
                                              Titel
                                              + +
                                              +
                                              +
                                              Beschreibung
                                              + +
                                              +
                                              +
                                              +
                                              Preis
                                              + +
                                              +
                                              +
                                              Zustand
                                              + +
                                              +
                                              +
                                              +
                                              +
                                              Marke
                                              + +
                                              +
                                              +
                                              Größe
                                              + +
                                              +
                                              +
                                              +
                                              Kategorie (Vinted)
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              Farbe
                                              + +
                                              +
                                              +
                                              AI-Kategorie (Original)
                                              + +
                                              +
                                              +
                                              +
                                              Tags
                                              + +
                                              + +
                                              + + +
                                              + + +

                                              Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                              +
                                              + +
                                              + Entwurf speichern oder veröffentlichen +
                                              + + + + +
                                              +
                                              + +
                                              + `; + + 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 = String(value).trim().toLowerCase(); + if (!normalized) return false; + let trigger = null; + for (const label of document.querySelectorAll('label')) { + if (label.closest('#al-panel')) continue; + const text = label.textContent.trim().toLowerCase(); + if (labels.some(item => text.includes(item.toLowerCase()))) { + trigger = label.parentElement?.querySelector('button,[role="button"],select,input') || (label.htmlFor ? document.getElementById(label.htmlFor) : null); + if (trigger) break; + } + } + if (!trigger) { + for (const el of document.querySelectorAll('button,[role="button"]')) { + if (el.closest('#al-panel')) continue; + const text = el.textContent.trim().toLowerCase(); + if (labels.some(item => text === item.toLowerCase() || text.startsWith(item.toLowerCase()))) { trigger = el; break; } + } + } + if (!trigger) return false; + if (trigger.tagName === 'INPUT') { 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 = '
                                              Keine Kategorie gefunden
                                              '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                              +
                                              ${esc(m.name)}
                                              +
                                              ${esc(m.path.slice(0, -1).join(' > '))}
                                              +
                                              + `).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 = '
                                              '; + for (const main of VINTED_CATEGORIES) { + html += `
                                              `; + html += `
                                              ${esc(main.name)}
                                              `; + html += `
                                              `; + if (main.children) { + for (const sub of main.children) { + html += `
                                              `; + html += `
                                              ${esc(sub.name)}
                                              `; + html += `
                                              `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                              ${esc(leaf.name)}
                                              `; + } + } + html += '
                                              '; + } + } + html += '
                                              '; + } + html += '
                                              '; + 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 = ` +
                                              +
                                              +
                                              ${esc(cat.name)}
                                              +
                                              ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                              +
                                              + +
                                              + `; + 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 findField(selectors) { + for (const s of selectors) { const el = document.querySelector(s); if (el) return el; } + return null; + } + + function findByLabel(text, tag) { + for (const label of document.querySelectorAll('label')) { + if (label.textContent.toLowerCase().includes(text.toLowerCase())) { + const el = label.querySelector(tag) || label.parentElement?.querySelector(tag); + if (el) return el; + if (label.htmlFor) return document.getElementById(label.htmlFor); + } + } + return null; + } + + function fillVintedForm(fields) { + let filled = 0; + + const titleEl = findField(['[data-testid="title-input"] input', 'input[name="title"]', '[class*="Title"] input']) + || 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 = ` +
                                              +
                                              + Modus: ${modeText} +
                                              +
                                              +
                                              +
                                              + 0 / ${STATE.batchProgress.total} + 0% +
                                              +
                                              + +
                                              +
                                              + `; + 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 += `
                                              • ${esc(title.substring(0, 30))}... ${step}
                                              `; + 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 += `
                                              ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                              `; + 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(); + 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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon-48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon.svg b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/manifest.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/manifest.json new file mode 100644 index 0000000..8362739 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/manifest.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.html new file mode 100644 index 0000000..1a7b8b2 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                              + + + + + + +
                                              + Vendoo Link v1.6.0 + Nur ausfüllen = kein Entwurf +
                                              +
                                              + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.js new file mode 100644 index 0000000..f51e29b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/popup.js @@ -0,0 +1,178 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                              +
                                              ${escapeHtml(item.title || 'Produktbild')}
                                              +
                                              +
                                              +
                                              ${escapeHtml(item.title || 'Ohne Titel')}
                                              +
                                              ${formatPrice(item.price)}
                                              +
                                              +
                                              ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                              +
                                              ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                              +
                                              + + + + +
                                              +
                                              +
                                              + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/vinted-categories.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/firefox/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon-48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon.svg b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.chromium.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.chromium.json new file mode 100644 index 0000000..dc27b67 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.chromium.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.firefox.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.firefox.json new file mode 100644 index 0000000..8362739 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/manifest.firefox.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.6.0", + "description": "Vendoo Link mit integriertem Vendoo-Branding, Queue, sicherem Auto-Fill, Bildtransfer und Publishing für Vinted.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.html new file mode 100644 index 0000000..1a7b8b2 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                              + + + + + + +
                                              + Vendoo Link v1.6.0 + Nur ausfüllen = kein Entwurf +
                                              +
                                              + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.js new file mode 100644 index 0000000..f51e29b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/popup.js @@ -0,0 +1,178 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                              +
                                              ${escapeHtml(item.title || 'Produktbild')}
                                              +
                                              +
                                              +
                                              ${escapeHtml(item.title || 'Ohne Titel')}
                                              +
                                              ${formatPrice(item.price)}
                                              +
                                              +
                                              ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                              +
                                              ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                              +
                                              + + + + +
                                              +
                                              +
                                              + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/vinted-categories.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/extensions/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-claude.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-claude.mjs new file mode 100644 index 0000000..92f5195 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-claude.mjs @@ -0,0 +1,31 @@ +import Anthropic from '@anthropic-ai/sdk'; + +let client; + +function getClient() { + if (!client) client = new Anthropic(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image', + source: { type: 'base64', media_type: img.mediaType, data: img.base64 }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1500, + system: systemPrompt, + messages: [{ role: 'user', content }], + }); + + const text = response.content[0].text; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-local.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-local.mjs new file mode 100644 index 0000000..fe3c34e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-local.mjs @@ -0,0 +1,41 @@ +export const MODELS = [ + { id: 'llama3.2-vision', name: 'Llama 3.2 Vision (11B)' }, + { id: 'llava', name: 'LLaVA 1.6 (7B)' }, + { id: 'moondream', name: 'Moondream 2 (1.8B, schnell)' }, + { id: 'gemma3', name: 'Gemma 3 (Vision)' }, +]; + +export async function generate(images, systemPrompt, model) { + const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + const modelId = model || MODELS[0].id; + + const ollamaImages = images.map(img => img.base64); + + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + images: ollamaImages, + }, + ], + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama Fehler (${res.status}): ${text.slice(0, 200)}`); + } + + const data = await res.json(); + const text = data.message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-jobs.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-jobs.mjs new file mode 100644 index 0000000..d357af3 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-jobs.mjs @@ -0,0 +1,101 @@ +import { + createAiModelGeneration, + updateAiModelGeneration, + createAiModelPhoto, + createAiModelJob, + updateAiModelJob, + getAiModelJob, + getPendingAiModelJobs, +} from './db.mjs'; +import { generateAiModelPhotos } from './ai-model-photos.mjs'; + +let processing = false; +let loopStarted = false; + +async function processOneJob(job) { + updateAiModelJob(job.id, { status: 'running', progress: 10, error_message: null }); + try { + const payload = job.payload_json || {}; + const result = await generateAiModelPhotos({ ...payload, settings: payload.settings || {} }); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || result.mode || 'model', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || payload.mode || 'model', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + mode: payload.mode || result.mode || 'model', + }); + updateAiModelJob(job.id, { status: 'completed', progress: 100, generation_id: generation.id }); + return { generation, photos, provider: result.provider || null }; + } catch (err) { + updateAiModelJob(job.id, { status: 'failed', progress: 100, error_message: err.message || 'Unbekannter Fehler' }); + throw err; + } +} + +async function processQueue() { + if (processing) return; + processing = true; + try { + const pending = getPendingAiModelJobs(5); + for (const job of pending) { + try { + await processOneJob(job); + } catch {} + } + } finally { + processing = false; + } +} + +export function startAiModelJobLoop() { + if (loopStarted) return; + loopStarted = true; + setInterval(() => { processQueue().catch(() => {}); }, 2500).unref?.(); +} + +export function enqueueAiModelJob(payload = {}) { + const job = createAiModelJob({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || 'model', + preset: payload.preset || 'mixed', + variants: payload.variants || 1, + status: 'queued', + progress: 0, + payload_json: payload, + }); + processQueue().catch(() => {}); + return job; +} + +export function getAiModelJobWithRelations(id) { + return getAiModelJob(id); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-photos.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-photos.mjs new file mode 100644 index 0000000..72924d9 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-model-photos.mjs @@ -0,0 +1,604 @@ +import OpenAI from 'openai'; +import { join, dirname, extname } from 'path'; +import { fileURLToPath } from 'url'; +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const uploadsDir = join(__dirname, '..', 'uploads'); +const modelDir = join(uploadsDir, 'ai-models'); +mkdirSync(modelDir, { recursive: true }); + +const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models?output_modalities=image&sort=newest'; +const OPENROUTER_IMAGES_URL = 'https://openrouter.ai/api/v1/images/generations'; + +let openaiClient; +function getOpenAIClient() { + if (!process.env.OPENAI_API_KEY) throw new Error('OPENAI_API_KEY fehlt.'); + if (!openaiClient) openaiClient = new OpenAI(); + return openaiClient; +} + +const BLOCKED_KEYWORDS = [ + 'unterwäsche', 'lingerie', 'dessous', 'bikini', 'bademode', 'badeanzug', 'bh', 'slip', 'string', 'tanga', + 'babydoll', 'korsage', 'corsage', 'fetisch', 'latex', 'erotik', 'transparent', 'see-through', 'mesh', 'sexy' +]; +const KIDS_KEYWORDS = ['baby', 'kinder', 'kind', 'jungen', 'mädchen', 'maedchen', 'kleinkind', 'kids']; +const CLOTHING_KEYWORDS = ['shirt', 't-shirt', 'top', 'bluse', 'hemd', 'hoodie', 'pullover', 'sweatshirt', 'jacke', 'mantel', 'kleid', 'rock', 'hose', 'jeans', 'cardigan', 'sakko', 'blazer', 'overall', 'jumpsuit', 'schuhe', 'sneaker', 'tasche', 'bekleidung', 'fashion', 'mode']; +const PRESET_VARIANTS = { + studio: ['helles Studio mit weichem Schattenwurf', 'neutraler Editorial-Look vor hellem Hintergrund', 'cleaner Studio-Catalog-Look'], + indoor: ['natürliche Indoor-Szene in modernem Wohnraum', 'ruhige Boutique-Atmosphäre mit natürlichem Licht', 'casual Lifestyle-Interior mit sauberem Hintergrund'], + outdoor: ['ruhige urbane Außenumgebung bei natürlichem Tageslicht', 'dezente Outdoor-Szene mit unscharfem Hintergrund', 'cleaner Streetstyle-Look mit weichem Licht'], + mixed: ['helles Studio mit weichem Schattenwurf', 'natürliche Indoor-Szene in modernem Wohnraum', 'ruhige urbane Außenumgebung bei natürlichem Tageslicht'], +}; + +function safeJsonParse(text, fallback = null) { + try { return JSON.parse(text); } catch {} + const match = String(text || '').match(/\{[\s\S]*\}/); + if (!match) return fallback; + try { return JSON.parse(match[0]); } catch { return fallback; } +} + +function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } +function numeric(val) { const num = Number(val ?? 0); return Number.isFinite(num) ? num : 0; } + +function normalizeListingContext(input = {}) { + return { + title: String(input.title || '').trim(), + category: String(input.category || '').trim(), + brand: String(input.brand || '').trim(), + color: String(input.color || '').trim(), + size: String(input.size || '').trim(), + condition: String(input.condition || '').trim(), + seller_notes: String(input.seller_notes || '').trim(), + platform: String(input.platform || '').trim() || 'vinted', + }; +} + +function keywordFlags(context) { + const hay = `${context.title} ${context.category} ${context.seller_notes}`.toLowerCase(); + return { + blocked: BLOCKED_KEYWORDS.filter(word => hay.includes(word)), + kids: KIDS_KEYWORDS.filter(word => hay.includes(word)), + clothing: CLOTHING_KEYWORDS.some(word => hay.includes(word)), + }; +} + +function heuristicAnalysis(context) { + const flags = keywordFlags(context); + const garmentType = CLOTHING_KEYWORDS.find(word => (`${context.title} ${context.category}`).toLowerCase().includes(word)) || 'Kleidungsstück'; + return { + category: context.category || 'Mode', + garmentType, + color: context.color || null, + pattern: null, + material: null, + fit: null, + style: null, + age_group: flags.kids.length ? 'kids' : 'adult', + suitable_for_model_photos: flags.blocked.length === 0, + has_person: false, + confidence: 'low', + notes: flags.clothing ? 'Heuristik erkennt Modeartikel.' : 'Heuristik ohne visuelle Analyse.', + }; +} + +function getUploadPath(filename) { return join(uploadsDir, String(filename || '')); } + +function fileToDataUrl(filename) { + const full = getUploadPath(filename); + const ext = extname(full).toLowerCase(); + const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; + const b64 = readFileSync(full).toString('base64'); + return `data:${mime};base64,${b64}`; +} + +function parseOpenRouterModel(model = {}) { + const inputModalities = model.architecture?.input_modalities || []; + const outputModalities = model.architecture?.output_modalities || []; + return { + id: model.id, + name: model.name || model.id, + description: model.description || '', + created: model.created || null, + input_modalities: inputModalities, + output_modalities: outputModalities, + pricing: model.pricing || {}, + supported_parameters: model.supported_parameters || [], + is_free: isOpenRouterFreeModel(model), + supports_image_input: inputModalities.includes('image'), + supports_image_output: outputModalities.includes('image'), + }; +} + +function priceIsZero(value) { + if (value === undefined || value === null || value === '') return true; + const number = Number(value); + return Number.isFinite(number) && number === 0; +} + +function isOpenRouterFreeModel(model = {}) { + const id = String(model.id || '').toLowerCase(); + const name = String(model.name || '').toLowerCase(); + if (id.endsWith(':free') || name.includes('(free)')) return true; + + const pricing = model.pricing || {}; + // Nur Preise bewerten, die für eine normale Bildanforderung relevant sind. + // Optionale Web-/Audio-Preise dürfen ein ansonsten kostenloses Bildmodell nicht ausschließen. + const relevantKeys = ['request', 'prompt', 'completion', 'image']; + const present = relevantKeys.filter(key => pricing[key] !== undefined && pricing[key] !== null && pricing[key] !== ''); + return present.length > 0 && present.every(key => priceIsZero(pricing[key])); +} + +function getOpenRouterHeaders() { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY fehlt.'); + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }; + if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL; + if (process.env.OPENROUTER_APP_NAME) headers['X-Title'] = process.env.OPENROUTER_APP_NAME; + return headers; +} + +export async function fetchOpenRouterImageModels({ freeOnly = true } = {}) { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { ...getOpenRouterHeaders(), 'Cache-Control': 'no-cache' }, + cache: 'no-store', + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`OpenRouter Models API Fehler (${response.status})${body ? `: ${body.slice(0, 180)}` : ''}`); + } + const payload = await response.json(); + const list = Array.isArray(payload?.data) ? payload.data.map(parseOpenRouterModel) : []; + const imageModels = list.filter(model => model.supports_image_output); + const filtered = freeOnly ? imageModels.filter(model => model.is_free) : imageModels; + return filtered.sort((a, b) => Number(b.created || 0) - Number(a.created || 0)); +} + +export async function getOpenRouterImageDiagnostics() { + const allModels = await fetchOpenRouterImageModels({ freeOnly: false }); + const freeModels = allModels.filter(model => model.is_free); + return { + image_model_count: allModels.length, + free_image_model_count: freeModels.length, + free_models: freeModels, + all_models: allModels, + message: freeModels.length + ? `${freeModels.length} kostenlose OpenRouter-Bildmodelle verfügbar.` + : 'Aktuell wurde kein kostenloses OpenRouter-Modell mit Bildausgabe gefunden.', + }; +} + +async function resolveImageProviders(settings = {}) { + const preference = String(settings.ai_model_photo_provider || 'auto').toLowerCase(); + const openrouterFreeOnly = String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const configuredOpenRouterModel = String(settings.ai_model_photo_openrouter_model || '').trim(); + const configuredOpenAiModel = String(settings.ai_model_photo_openai_model || 'gpt-image-1').trim() || 'gpt-image-1'; + const candidates = []; + const discoveryErrors = []; + + const allowOpenRouter = preference === 'openrouter' || preference === 'auto'; + const allowOpenAi = preference === 'openai' || preference === 'auto'; + + if (allowOpenRouter && process.env.OPENROUTER_API_KEY) { + try { + const models = await fetchOpenRouterImageModels({ freeOnly: openrouterFreeOnly }); + const ordered = []; + const configured = models.find(model => model.id === configuredOpenRouterModel); + if (configured) ordered.push(configured); + for (const model of models) { + if (!ordered.some(entry => entry.id === model.id)) ordered.push(model); + } + for (const model of ordered) { + candidates.push({ + provider: 'openrouter', + model: model.id, + label: model.name, + free: model.is_free, + supports_image_input: model.supports_image_input, + supported_parameters: model.supported_parameters || [], + discovered_models: models, + }); + } + if (!ordered.length) discoveryErrors.push(openrouterFreeOnly ? 'Aktuell sind keine kostenlosen OpenRouter-Modelle mit Bildausgabe verfügbar.' : 'Keine passenden OpenRouter-Bildmodelle gefunden.'); + } catch (error) { + discoveryErrors.push(error.message || 'OpenRouter-Modellabfrage fehlgeschlagen.'); + } + } else if (preference === 'openrouter' && !process.env.OPENROUTER_API_KEY) { + discoveryErrors.push('OPENROUTER_API_KEY fehlt.'); + } + + if (allowOpenAi && process.env.OPENAI_API_KEY) { + candidates.push({ + provider: 'openai', + model: configuredOpenAiModel, + label: configuredOpenAiModel, + free: false, + discovered_models: [], + }); + } else if (preference === 'openai' && !process.env.OPENAI_API_KEY) { + discoveryErrors.push('OPENAI_API_KEY fehlt.'); + } + + if (!candidates.length) { + throw new Error(discoveryErrors.filter(Boolean).join(' | ') || 'Weder OpenRouter noch OpenAI sind für AI-Model-Fotos konfiguriert.'); + } + return candidates; +} + +export async function analyzeGarmentPhotos(photos = [], contextInput = {}) { + const context = normalizeListingContext(contextInput); + const base = heuristicAnalysis(context); + if (!photos.length || !process.env.OPENAI_API_KEY) return base; + + const content = photos.slice(0, 4).map(photo => ({ type: 'image_url', image_url: { url: fileToDataUrl(photo), detail: 'high' } })); + content.push({ + type: 'text', + text: `Analysiere diese Produktfotos für ein Second-Hand-Listing. Antworte ausschließlich als JSON mit den Feldern: category, garmentType, color, pattern, material, fit, style, age_group (adult|kids|unknown), suitable_for_model_photos (boolean), has_person (boolean), confidence, notes. Kontext: ${JSON.stringify(context)}. If a person is visible set has_person=true. If the item appears to be underwear, swimwear, lingerie, transparent erotic wear or child clothing, mark suitable_for_model_photos=false.` + }); + + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0.1, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Du bist eine strenge Safety- und Produktanalyse für einen Mode-Listing-Generator.' }, + { role: 'user', content }, + ], + max_tokens: 500, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed ? { ...base, ...parsed } : base; + } catch { + return base; + } +} + +export async function precheckAiModelPhotos(input = {}, settings = {}) { + const context = normalizeListingContext(input); + const photos = Array.isArray(input.photos) ? input.photos.filter(Boolean) : []; + const variants = clamp(Number(input.variants || settings.ai_model_photo_default_variants || 1), 1, 3); + const preset = ['studio', 'indoor', 'outdoor', 'mixed'].includes(input.preset) ? input.preset : (settings.ai_model_photo_default_preset || 'mixed'); + const mode = ['model', 'ghost_mannequin', 'flatlay'].includes(input.mode) ? input.mode : (settings.ai_model_photo_default_mode || 'model'); + const flags = keywordFlags(context); + if (!photos.length) return { allowed: false, reason: 'Keine Fotos vorhanden.', context, variants, preset, mode, checks: [] }; + if (flags.blocked.length) return { allowed: false, reason: `Blockierter Produkttyp erkannt: ${flags.blocked[0]}.`, context, variants, preset, mode, checks: ['block_underwear'] }; + if (flags.kids.length) return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['block_kids'] }; + const analysis = await analyzeGarmentPhotos(photos, context); + if (mode === 'model' && analysis.has_person) return { allowed: false, reason: 'Auf den Originalfotos ist bereits eine Person zu sehen.', context, variants, preset, mode, checks: ['has_person'], analysis }; + if (String(analysis.age_group || '').toLowerCase() === 'kids') return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['vision_kids'], analysis }; + if (analysis.suitable_for_model_photos === false) return { allowed: false, reason: 'Der Artikel ist laut Sicherheitsprüfung nicht für AI-Model-Fotos geeignet.', context, variants, preset, mode, checks: ['vision_block'], analysis }; + const hay = `${context.title} ${context.category}`.toLowerCase(); + const looksLikeClothing = flags.clothing || CLOTHING_KEYWORDS.some(word => hay.includes(word)) || !!analysis.garmentType; + if (!looksLikeClothing) return { allowed: false, reason: 'AI-Model-Fotos sind aktuell nur für Kleidung und Modeartikel vorgesehen.', context, variants, preset, mode, checks: ['not_clothing'], analysis }; + return { allowed: true, reason: 'Freigegeben', context, variants, preset, mode, checks: ['adult_only', 'fully_clothed', 'non_sexual'], analysis }; +} + +function buildVariantPrompt({ analysis, context, preset, variantIndex, mode }) { + const options = PRESET_VARIANTS[preset] || PRESET_VARIANTS.mixed; + const scene = options[variantIndex % options.length]; + const productDetails = [ + analysis.garmentType || context.category || 'Kleidungsstück', + analysis.color || context.color || null, + analysis.pattern ? `Muster: ${analysis.pattern}` : null, + analysis.material ? `Material: ${analysis.material}` : null, + analysis.fit ? `Schnitt/Fit: ${analysis.fit}` : null, + analysis.style ? `Stil: ${analysis.style}` : null, + context.brand ? `Marke: ${context.brand}` : null, + context.size ? `Größe: ${context.size}` : null, + context.condition ? `Zustand: ${context.condition}` : null, + ].filter(Boolean).join(', '); + + if (mode === 'ghost_mannequin') { + return [ + 'Generate a photorealistic apparel photo for a second-hand marketplace.', + 'Show the clothing item as a clean ghost mannequin presentation with no visible real person.', + 'The garment must look naturally worn-in-form but with an invisible mannequin / hollow-neck apparel-photo style.', + 'No visible skin, no nudity, no erotic styling, no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + 'Use a neutral realistic marketplace background and keep the full garment easy to inspect.', + variantIndex === 0 ? 'Use a clear front view.' : variantIndex === 1 ? 'Use a 3/4 angle or detail-friendly secondary angle.' : 'Use an alternate clean catalog angle.', + ].join(' '); + } + + if (mode === 'flatlay') { + return [ + 'Generate a photorealistic flat lay marketplace photo for a clothing item.', + 'No person should be present. Arrange the garment neatly on a clean, realistic surface.', + 'The item must be the focus, fully visible, symmetrical where appropriate, and suitable for second-hand listing use.', + 'No erotic styling, no lingerie presentation, and no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + variantIndex === 0 ? 'Use a clean straight-down hero shot.' : variantIndex === 1 ? 'Use a slightly styled flat lay with minimal accessories or folds only if tasteful.' : 'Use a third variation with another neat layout angle.', + ].join(' '); + } + + return [ + 'Generate a photorealistic fashion marketplace image featuring a fictional adult model wearing the described clothing item.', + 'The person must be clearly adult, naturally looking, fully clothed, non-sexualized, and shown in a realistic pose.', + 'No nudity, no lingerie styling, no swimwear styling, no fetish aesthetics, no transparent erotic look, no minor-looking person.', + `The clothing item is the focus and should closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + 'Show the full garment clearly, with natural proportions and a believable body pose. The background must be clean and realistic.', + 'Create a safe, tasteful resale-photo style image suitable for second-hand marketplace listings.', + variantIndex === 0 ? 'Use a calm front or 3/4 front view.' : variantIndex === 1 ? 'Use a slightly different angle with natural posture.' : 'Use a third angle or context while keeping the product easy to inspect.', + context.seller_notes ? `Additional seller notes for accuracy: ${context.seller_notes.slice(0, 300)}` : '', + ].filter(Boolean).join(' '); +} + +async function runOutputSafetyCheck(filePath) { + if (!process.env.OPENAI_API_KEY) return { safe: true, verdict: 'unchecked', notes: 'Keine zusätzliche API-Moderation konfiguriert.' }; + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Prüfe Modebilder streng auf Nacktheit, Sexualisierung, Minderjährige oder unzulässige Inhalte. Antworte nur als JSON.' }, + { role: 'user', content: [ + { type: 'image_url', image_url: { url: `data:image/png;base64,${readFileSync(filePath).toString('base64')}`, detail: 'low' } }, + { type: 'text', text: 'Antworte mit JSON: {"safe": boolean, "verdict": "safe|blocked", "notes": string}. Block if nudity, lingerie pose, erotic styling or minor-looking person is visible.' }, + ] }, + ], + max_tokens: 180, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed || { safe: true, verdict: 'safe', notes: 'Fallback safe.' }; + } catch { + return { safe: true, verdict: 'unchecked', notes: 'Moderation nicht verfügbar.' }; + } +} + +async function generateWithOpenAI(prompt, model) { + const response = await getOpenAIClient().images.generate({ model, prompt, size: '1024x1024', quality: 'medium' }); + return response.data?.[0]?.b64_json || null; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 90000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (error?.name === 'AbortError') throw new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`); + throw error; + } finally { + clearTimeout(timeout); + } +} + +function findImageValue(value) { + if (!value) return null; + if (typeof value === 'string') { + if (value.startsWith('data:image/')) return { type: 'base64', value: value.split(',')[1] || null }; + if (/^https?:\/\//i.test(value)) return { type: 'url', value }; + const dataMatch = value.match(/data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=]+)/i); + if (dataMatch) return { type: 'base64', value: dataMatch[1] }; + const markdownMatch = value.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i); + if (markdownMatch) return { type: 'url', value: markdownMatch[1] }; + return null; + } + if (Array.isArray(value)) { + for (const entry of value) { + const found = findImageValue(entry); + if (found) return found; + } + return null; + } + if (typeof value === 'object') { + if (typeof value.b64_json === 'string') return { type: 'base64', value: value.b64_json }; + const candidates = [ + value.url, + value.image_url?.url, + value.image_url, + value.source?.data, + value.data, + value.images, + value.content, + ]; + for (const candidate of candidates) { + const found = findImageValue(candidate); + if (found) return found; + } + } + return null; +} + +function extractOpenRouterImage(payload = {}) { + return findImageValue([ + payload?.data, + payload?.images, + payload?.choices?.[0]?.message?.images, + payload?.choices?.[0]?.message?.content, + payload?.output, + ]); +} + +async function downloadImageAsBase64(url) { + const response = await fetchWithTimeout(url, {}, 45000); + if (!response.ok) throw new Error(`Bild-Download fehlgeschlagen (${response.status})`); + return Buffer.from(await response.arrayBuffer()).toString('base64'); +} + +async function imageResultToBase64(result) { + if (!result?.value) return null; + return result.type === 'url' ? downloadImageAsBase64(result.value) : result.value; +} + +async function generateWithOpenRouterChat(prompt, candidate, sourceDataUrl = null) { + const content = []; + if (sourceDataUrl && candidate.supports_image_input) { + content.push({ type: 'image_url', image_url: { url: sourceDataUrl } }); + } + content.push({ type: 'text', text: prompt }); + + const baseBody = { + model: candidate.model, + modalities: ['image', 'text'], + messages: [{ role: 'user', content }], + stream: false, + }; + const requestBodies = [ + { ...baseBody, image_config: { aspect_ratio: '1:1' }, provider: { allow_fallbacks: true } }, + { ...baseBody, provider: { allow_fallbacks: true } }, + baseBody, + ]; + const errors = []; + + for (const body of requestBodies) { + const response = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify(body), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (response.ok) { + const result = extractOpenRouterImage(payload); + const base64 = await imageResultToBase64(result); + if (base64) return base64; + errors.push('Chat API antwortete erfolgreich, enthielt aber keine Bilddaten.'); + continue; + } + + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + errors.push(`Chat API ${response.status}: ${message}`); + if (![400, 404, 422].includes(response.status)) break; + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithOpenRouterImagesApi(prompt, candidate) { + const response = await fetchWithTimeout(OPENROUTER_IMAGES_URL, { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify({ model: candidate.model, prompt, size: '1024x1024', n: 1 }), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (!response.ok) { + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + throw new Error(`Images API ${response.status}: ${message}`); + } + const result = extractOpenRouterImage(payload); + return imageResultToBase64(result); +} + +async function generateWithOpenRouter(prompt, candidate, sourceDataUrl = null) { + const errors = []; + try { + const chatImage = await generateWithOpenRouterChat(prompt, candidate, sourceDataUrl); + if (chatImage) return chatImage; + errors.push('Chat API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Chat API fehlgeschlagen.'); + } + + try { + const apiImage = await generateWithOpenRouterImagesApi(prompt, candidate); + if (apiImage) return apiImage; + errors.push('Images API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Images API fehlgeschlagen.'); + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithProviderCandidate(prompt, candidate, sourceDataUrl = null) { + const base64 = candidate.provider === 'openrouter' + ? await generateWithOpenRouter(prompt, candidate, sourceDataUrl) + : await generateWithOpenAI(prompt, candidate.model); + if (!base64) throw new Error(`${candidate.label || candidate.model} lieferte keine Bilddaten zurück.`); + return base64; +} + +async function generateWithProviderFallback(prompt, candidates = [], sourceDataUrl = null) { + const attempts = []; + for (const candidate of candidates) { + try { + const base64 = await generateWithProviderCandidate(prompt, candidate, sourceDataUrl); + return { base64, candidate, attempts }; + } catch (error) { + attempts.push({ + provider: candidate.provider, + model: candidate.model, + label: candidate.label, + error: error.message || 'Unbekannter Fehler', + }); + } + } + const summary = attempts.map(item => `${item.label || item.model}: ${item.error}`).join(' | '); + const error = new Error(summary || 'Alle Bildmodelle sind fehlgeschlagen.'); + error.attempts = attempts; + throw error; +} + +export async function generateAiModelPhotos(input = {}) { + const precheck = await precheckAiModelPhotos(input, input.settings || {}); + if (!precheck.allowed) { + const error = new Error(precheck.reason || 'AI-Model-Fotos wurden blockiert.'); + error.code = 'AI_MODEL_PRECHECK_FAILED'; + error.precheck = precheck; + throw error; + } + + let providerCandidates = await resolveImageProviders(input.settings || {}); + const context = precheck.context; + const analysis = precheck.analysis || heuristicAnalysis(context); + const variants = precheck.variants; + const preset = precheck.preset; + const mode = precheck.mode || 'model'; + const created = []; + const sourceDataUrl = input.photos?.[0] ? fileToDataUrl(input.photos[0]) : null; + let lastProviderInfo = providerCandidates[0] || null; + const providerAttempts = []; + + for (let index = 0; index < variants; index += 1) { + const prompt = buildVariantPrompt({ analysis, context, preset, variantIndex: index, mode }); + const generated = await generateWithProviderFallback(prompt, providerCandidates, sourceDataUrl); + const base64 = generated.base64; + const providerInfo = generated.candidate; + lastProviderInfo = providerInfo; + providerAttempts.push(...(generated.attempts || [])); + providerCandidates = [providerInfo, ...providerCandidates.filter(candidate => !(candidate.provider === providerInfo.provider && candidate.model === providerInfo.model))]; + + const filename = `ai-models/${randomUUID()}.png`; + const filePath = join(uploadsDir, filename); + writeFileSync(filePath, Buffer.from(base64, 'base64')); + + const moderation = await runOutputSafetyCheck(filePath); + if (!moderation.safe) continue; + created.push({ + filename, + prompt, + moderation, + variant_index: index + 1, + preset, + source_photo: input.photos?.[0] || null, + public_url: `/uploads/${filename}`, + provider: providerInfo.provider, + provider_label: providerInfo.label, + provider_model: providerInfo.model, + provider_free: providerInfo.free, + mode, + }); + } + + if (!created.length) throw new Error('Es konnten keine sicheren AI-Model-Fotos erzeugt werden.'); + + return { + precheck, + provider: lastProviderInfo, + provider_attempts: providerAttempts, + mode, + prompt_summary: `${analysis.garmentType || context.category || 'Kleidungsstück'} · ${mode} · ${preset} · ${created.length} Variante(n)`, + photos: created, + }; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openai.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openai.mjs new file mode 100644 index 0000000..54abc8e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openai.mjs @@ -0,0 +1,33 @@ +import OpenAI from 'openai'; + +let client; + +function getClient() { + if (!client) client = new OpenAI(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}`, detail: 'high' }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().chat.completions.create({ + model: 'gpt-4o', + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }); + + const text = response.choices[0].message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openrouter.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openrouter.mjs new file mode 100644 index 0000000..f1a86c0 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-openrouter.mjs @@ -0,0 +1,110 @@ +const OPENROUTER_BASE = 'https://openrouter.ai/api/v1'; + +export const MODELS = [ + { id: 'google/gemini-3.1-flash-lite-image-20260630', name: 'Gemini 3.1 Flash Lite (~0)', free: false, priority: 1 }, + { id: 'google/gemini-3.1-flash-image-20260528', name: 'Gemini 3.1 Flash Image (~0)', free: false, priority: 2 }, + { id: 'google/gemini-3-pro-image-20260528', name: 'Gemini 3 Pro Image', free: false, priority: 3 }, + { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', name: 'Nemotron 3 Nano Omni (free)', free: true, priority: 4 }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)', free: true, priority: 5 }, + { id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)', free: true, priority: 6 }, + { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 (free)', free: true, priority: 7 }, + { id: 'qwen/qwen3-235b-a22b:free', name: 'Qwen 3 235B (free)', free: true, priority: 8 }, +]; + +function extractJSON(text) { + if (!text) return null; + // Try direct parse first + try { return JSON.parse(text); } catch {} + // Find outermost { ... } + let depth = 0, start = -1; + for (let i = 0; i < text.length; i++) { + if (text[i] === '{') { if (depth === 0) start = i; depth++; } + else if (text[i] === '}') { depth--; if (depth === 0 && start >= 0) { + try { return JSON.parse(text.substring(start, i + 1)); } catch {} + }} + } + // Try fixing common issues: trailing commas, unescaped newlines + const match = text.match(/\{[\s\S]*\}/); + if (match) { + let cleaned = match[0] + .replace(/,\s*([}\]])/g, '$1') // trailing commas + .replace(/[\x00-\x1f]/g, m => // control chars in strings + m === '\n' ? '\\n' : m === '\r' ? '\\r' : m === '\t' ? '\\t' : '' + ); + try { return JSON.parse(cleaned); } catch {} + } + return null; +} + +async function callModel(modelId, images, systemPrompt) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY nicht gesetzt'); + + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}` }, + }); + } + content.push({ + type: 'text', + text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + }); + + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'http://localhost:8124', + 'X-Title': 'Vendoo', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${res.status}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content; + if (!text) throw new Error('Leere Antwort'); + + const parsed = extractJSON(text); + if (!parsed) throw new Error('Kein valides JSON in Antwort'); + return parsed; +} + +export async function generate(images, systemPrompt, model) { + if (model) { + return callModel(model, images, systemPrompt); + } + + const ordered = [...MODELS].sort((a, b) => a.priority - b.priority); + const errors = []; + + for (const m of ordered) { + try { + console.log(`OpenRouter: Versuche ${m.name}...`); + const result = await callModel(m.id, images, systemPrompt); + console.log(`OpenRouter: Erfolg mit ${m.name}`); + return result; + } catch (err) { + const msg = err.message; + console.log(`OpenRouter: ${m.name} fehlgeschlagen: ${msg}`); + errors.push(`${m.name}: ${msg}`); + if (msg.includes('API_KEY') || msg.includes('nicht gesetzt')) break; + } + } + + throw new Error(`Alle Modelle fehlgeschlagen:\n${errors.join('\n')}`); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-router.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-router.mjs new file mode 100644 index 0000000..801ee4e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ai-router.mjs @@ -0,0 +1,33 @@ +import * as claude from './ai-claude.mjs'; +import * as openai from './ai-openai.mjs'; +import * as openrouter from './ai-openrouter.mjs'; +import * as local from './ai-local.mjs'; + +const providers = { claude, openai, openrouter, local }; + +export async function generate(provider, images, systemPrompt, model) { + const ai = providers[provider]; + if (!ai) throw new Error(`Unknown AI provider: ${provider}`); + return ai.generate(images, systemPrompt, model); +} + +export function getProviders() { + return [ + { id: 'claude', name: 'Claude', hasModels: false, needsKey: 'ANTHROPIC_API_KEY' }, + { id: 'openai', name: 'OpenAI', hasModels: false, needsKey: 'OPENAI_API_KEY' }, + { + id: 'openrouter', + name: 'OpenRouter', + hasModels: true, + models: openrouter.MODELS, + needsKey: 'OPENROUTER_API_KEY', + }, + { + id: 'local', + name: 'Lokal (Ollama)', + hasModels: true, + models: local.MODELS, + needsKey: null, + }, + ]; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/auth.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/auth.mjs new file mode 100644 index 0000000..e596776 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/auth.mjs @@ -0,0 +1,292 @@ +import { db } from './db.mjs'; +import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +// --- Schema --- +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'editor', + active INTEGER NOT NULL DEFAULT 1, + avatar_color TEXT, + last_active_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + invited_by INTEGER, + FOREIGN KEY (invited_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + ip TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + type TEXT NOT NULL DEFAULT 'login', + role TEXT DEFAULT 'editor', + used INTEGER NOT NULL DEFAULT 0, + expires_at TEXT NOT NULL, + created_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (created_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS login_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + ip TEXT, + user_agent TEXT, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +// Migration: add password_hash column if missing +try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {} + +const AVATAR_COLORS = [ + '#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b', + '#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c', +]; + +const SESSION_DURATION_MS = 3 * 24 * 60 * 60 * 1000; // 3 days +const LOGIN_TOKEN_EXPIRY_MIN = 15; +const INVITE_TOKEN_EXPIRY_HOURS = 48; + +function hashToken(token) { + return createHash('sha256').update(token).digest('hex'); +} + +function generateToken() { + return randomBytes(32).toString('hex'); +} + +function randomAvatarColor() { + return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; +} + +// --- Password Hashing (scrypt) --- +export function hashPassword(password) { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password, storedHash) { + if (!storedHash) return false; + const [salt, hash] = storedHash.split(':'); + if (!salt || !hash) return false; + const hashBuf = Buffer.from(hash, 'hex'); + const supplied = scryptSync(password, salt, 64); + return timingSafeEqual(hashBuf, supplied); +} + +// --- Users --- +export function createUser(email, name, role = 'editor', invitedBy = null, password = null) { + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) throw new Error('User existiert bereits'); + const color = randomAvatarColor(); + const pwHash = password ? hashPassword(password) : null; + const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash); + return getUser(r.lastInsertRowid); +} + +export function setPassword(userId, password) { + const pwHash = hashPassword(password); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(pwHash, userId); +} + +export function getUser(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); +} + +export function getUserByEmail(email) { + return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email); +} + +export function getUsers() { + return db.prepare('SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, CASE WHEN password_hash IS NOT NULL AND password_hash != \'\' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at').all(); +} + +export function updateUser(id, data) { + const allowed = ['name', 'role', 'active', 'avatar_color']; + const fields = [], values = []; + for (const [k, v] of Object.entries(data)) { + if (allowed.includes(k) && v !== undefined) { fields.push(`${k} = ?`); values.push(v); } + } + if (!fields.length) return getUser(id); + values.push(id); + db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getUser(id); +} + +export function deleteUser(id) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); +} + +export function touchUserActivity(userId) { + db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId); +} + +export function getUserCount() { + return db.prepare('SELECT COUNT(*) as count FROM users').get().count; +} + +// --- Sessions --- +export function createSession(userId, ip, userAgent) { + const token = generateToken(); + const hash = hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS).toISOString(); + db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)').run(userId, hash, ip, userAgent || '', expiresAt); + return { token, expiresAt }; +} + +export function validateSession(token) { + if (!token) return null; + const hash = hashToken(token); + const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.expires_at > datetime('now')").get(hash); + if (!session || !session.active) return null; + touchUserActivity(session.user_id); + return session; +} + +export function destroySession(token) { + const hash = hashToken(token); + db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(hash); +} + +export function destroySessionById(id) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(id); +} + +export function destroyUserSessions(userId) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); +} + +export function getUserSessions(userId) { + return db.prepare("SELECT id, ip, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId); +} + +export function getAllActiveSessions() { + return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.expires_at > datetime('now') ORDER BY s.created_at DESC").all(); +} + +// clean expired sessions periodically +export function cleanExpiredSessions() { + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); +} + +// --- Auth Tokens (Magic Links) --- +export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) { + const token = generateToken(); + const hash = hashToken(token); + const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN; + const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString(); + + // invalidate previous unused tokens of same type for same email + db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type); + + db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy); + return { token, expiresAt }; +} + +export function validateAuthToken(token) { + const hash = hashToken(token); + const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash); + if (!row) return null; + db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id); + return row; +} + +// --- Rate Limiting --- +const rateLimits = new Map(); + +export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) { + const now = Date.now(); + const entry = rateLimits.get(key); + if (!entry) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + if (now - entry.firstAttempt > windowMs) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + entry.count++; + if (entry.count > maxAttempts) { + const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000); + return { allowed: false, remaining: 0, retryAfter }; + } + return { allowed: true, remaining: maxAttempts - entry.count }; +} + +// --- CSRF --- +export function generateCsrfToken() { + return randomBytes(24).toString('hex'); +} + +// --- Audit Log --- +export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) { + db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip); +} + +export function getAuditLog(limit = 100, offset = 0) { + return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset); +} + +export function getAuditLogForUser(userId, limit = 50) { + return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +// --- Login History --- +export function recordLogin(userId, ip, userAgent, success = true) { + db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0); +} + +export function getLoginHistory(userId, limit = 20) { + return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +export function getRecentFailedLogins(email, windowMinutes = 15) { + const user = getUserByEmail(email); + if (!user) return 0; + const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`); + return r.count; +} + +// --- Setup: ensure at least one admin exists --- +export function ensureAdminExists() { + const count = getUserCount(); + return count === 0; +} + +export function isSetupMode() { + return getUserCount() === 0; +} + +// Clean expired sessions every 10 minutes +setInterval(cleanExpiredSessions, 10 * 60 * 1000); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/categories.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/categories.mjs new file mode 100644 index 0000000..708fc0c --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/categories.mjs @@ -0,0 +1,359 @@ +const CATEGORIES = { + vinted: [ + { id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, { id: 4, name: 'Kleider' }, { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, { id: 8, name: 'Jeans' }, { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, { id: 11, name: 'Sportbekleidung' }, { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 1226, name: 'Shorts' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, { id: 20, name: 'Sandalen' }, { id: 21, name: 'Ballerinas' }, + { id: 23, name: 'Sportschuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, { id: 26, name: 'Umhängetaschen' }, { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches' }, { id: 29, name: 'Geldbörsen & Etuis' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, { id: 32, name: 'Uhren' }, { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, { id: 35, name: 'Gürtel' }, { id: 36, name: 'Mützen & Hüte' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, { id: 39, name: 'Hautpflege' }, { id: 41, name: 'Parfum' }, + ]}, + ]}, + { id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, { id: 53, name: 'Hosen' }, { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, { id: 56, name: 'Jacken & Mäntel' }, { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, { id: 65, name: 'Sandalen' }, { id: 66, name: 'Sportschuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, { id: 69, name: 'Umhängetaschen' }, { id: 71, name: 'Geldbörsen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, { id: 73, name: 'Sonnenbrillen' }, { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, { id: 76, name: 'Mützen & Hüte' }, { id: 78, name: 'Schmuck' }, + ]}, + ]}, + { id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, { id: 104, name: 'Jacken & Mäntel' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, { id: 113, name: 'Jacken & Mäntel' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, { id: 131, name: 'Stiefel' }, { id: 132, name: 'Sandalen' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen' }, { id: 142, name: 'Bausteine' }, { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + ]}, + ]}, + { id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, { id: 201, name: 'Kerzen' }, { id: 202, name: 'Vasen' }, + ]}, + { id: 245, name: 'Küche', children: [ + { id: 210, name: 'Geschirr' }, { id: 211, name: 'Gläser & Tassen' }, { id: 213, name: 'Küchengeräte' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, { id: 222, name: 'Kissen & Decken' }, { id: 223, name: 'Teppiche' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, { id: 231, name: 'Tische' }, { id: 232, name: 'Stühle' }, + ]}, + ]}, + { id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane' }, { id: 301, name: 'Sachbücher' }, { id: 303, name: 'Comics & Manga' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, { id: 321, name: 'Konsolen' }, { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport', children: [ + { id: 330, name: 'Fitnessgeräte' }, { id: 331, name: 'Fahrräder' }, { id: 332, name: 'Camping' }, + ]}, + ]}, + { id: 1210, name: 'Elektronik', children: [ + { id: 253, name: 'Handys', children: [ + { id: 400, name: 'iPhones' }, { id: 401, name: 'Samsung' }, { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & Zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, { id: 411, name: 'Tablets' }, { id: 414, name: 'NAS & Server' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, { id: 431, name: 'Lautsprecher' }, + ]}, + ]}, + ], + + 'ebay-ka': [ + { id: 'mode', name: 'Mode & Beauty', children: [ + { id: 'damen', name: 'Damenmode', children: [ + { id: 'dk1', name: 'Oberteile & T-Shirts' }, { id: 'dk2', name: 'Kleider' }, + { id: 'dk3', name: 'Hosen & Jeans' }, { id: 'dk4', name: 'Jacken & Mäntel' }, + { id: 'dk5', name: 'Pullover & Strickwaren' }, { id: 'dk6', name: 'Schuhe' }, + { id: 'dk7', name: 'Taschen' }, { id: 'dk8', name: 'Accessoires' }, + ]}, + { id: 'herren', name: 'Herrenmode', children: [ + { id: 'hk1', name: 'T-Shirts & Hemden' }, { id: 'hk2', name: 'Hosen & Jeans' }, + { id: 'hk3', name: 'Jacken & Mäntel' }, { id: 'hk4', name: 'Pullover & Hoodies' }, + { id: 'hk5', name: 'Schuhe' }, { id: 'hk6', name: 'Accessoires' }, + ]}, + { id: 'beauty', name: 'Beauty & Gesundheit', children: [ + { id: 'bk1', name: 'Parfum' }, { id: 'bk2', name: 'Hautpflege' }, { id: 'bk3', name: 'Makeup' }, + ]}, + ]}, + { id: 'elektronik', name: 'Elektronik', children: [ + { id: 'handy', name: 'Handy & Telefon', children: [ + { id: 'ek1', name: 'Smartphones' }, { id: 'ek2', name: 'Zubehör' }, + ]}, + { id: 'pc', name: 'PC & Zubehör', children: [ + { id: 'ek3', name: 'Notebooks & Laptops' }, { id: 'ek4', name: 'PCs & Desktops' }, + { id: 'ek5', name: 'Monitore' }, { id: 'ek6', name: 'Netzwerk & Server' }, + { id: 'ek7', name: 'Drucker & Scanner' }, { id: 'ek8', name: 'Zubehör' }, + ]}, + { id: 'audio', name: 'Audio & HiFi', children: [ + { id: 'ek9', name: 'Kopfhörer' }, { id: 'ek10', name: 'Lautsprecher' }, { id: 'ek11', name: 'HiFi-Anlagen' }, + ]}, + { id: 'foto', name: 'Foto & Kamera', children: [ + { id: 'ek12', name: 'Digitalkameras' }, { id: 'ek13', name: 'Objektive' }, { id: 'ek14', name: 'Zubehör' }, + ]}, + { id: 'tv', name: 'TV & Video', children: [ + { id: 'ek15', name: 'Fernseher' }, { id: 'ek16', name: 'Streaming' }, + ]}, + { id: 'konsolen', name: 'Konsolen & Spiele', children: [ + { id: 'ek17', name: 'Konsolen' }, { id: 'ek18', name: 'Spiele' }, { id: 'ek19', name: 'Zubehör' }, + ]}, + ]}, + { id: 'haus', name: 'Haus & Garten', children: [ + { id: 'moebel', name: 'Möbel', children: [ + { id: 'hk1', name: 'Tische' }, { id: 'hk2', name: 'Stühle' }, { id: 'hk3', name: 'Regale & Schränke' }, + ]}, + { id: 'deko', name: 'Dekoration', children: [ + { id: 'hk4', name: 'Bilder & Poster' }, { id: 'hk5', name: 'Vasen & Kerzen' }, + ]}, + { id: 'kueche', name: 'Küche', children: [ + { id: 'hk6', name: 'Geschirr' }, { id: 'hk7', name: 'Geräte' }, + ]}, + { id: 'garten', name: 'Garten', children: [ + { id: 'hk8', name: 'Gartenmöbel' }, { id: 'hk9', name: 'Geräte & Werkzeug' }, + ]}, + ]}, + { id: 'kinder', name: 'Familie, Kind & Baby', children: [ + { id: 'kkleidung', name: 'Kinderkleidung', children: [ + { id: 'kk1', name: 'Mädchen' }, { id: 'kk2', name: 'Jungen' }, { id: 'kk3', name: 'Baby' }, + ]}, + { id: 'spielzeug', name: 'Spielzeug', children: [ + { id: 'kk4', name: 'Lego & Bausteine' }, { id: 'kk5', name: 'Puppen & Figuren' }, + { id: 'kk6', name: 'Brettspiele' }, { id: 'kk7', name: 'Outdoor-Spielzeug' }, + ]}, + ]}, + { id: 'freizeit', name: 'Freizeit & Sport', children: [ + { id: 'sport', name: 'Sport', children: [ + { id: 'fk1', name: 'Fitnessgeräte' }, { id: 'fk2', name: 'Fahrräder' }, + { id: 'fk3', name: 'Wintersport' }, { id: 'fk4', name: 'Ballsport' }, + ]}, + { id: 'buecher', name: 'Bücher & Medien', children: [ + { id: 'fk5', name: 'Bücher' }, { id: 'fk6', name: 'CDs & Vinyl' }, { id: 'fk7', name: 'DVDs & Blu-ray' }, + ]}, + ]}, + { id: 'auto', name: 'Auto, Rad & Boot', children: [ + { id: 'azubehoer', name: 'Zubehör & Teile', children: [ + { id: 'ak1', name: 'Reifen & Felgen' }, { id: 'ak2', name: 'Ersatzteile' }, + { id: 'ak3', name: 'Tuning & Styling' }, + ]}, + ]}, + ], + + 'ebay-de': [ + { id: 'fashion', name: 'Kleidung & Accessoires', children: [ + { id: 'ed-damen', name: 'Damenbekleidung', children: [ + { id: 'ed1', name: 'Tops & Shirts' }, { id: 'ed2', name: 'Kleider' }, + { id: 'ed3', name: 'Hosen' }, { id: 'ed4', name: 'Jacken & Mäntel' }, + { id: 'ed5', name: 'Pullover' }, { id: 'ed6', name: 'Röcke' }, + ]}, + { id: 'ed-herren', name: 'Herrenbekleidung', children: [ + { id: 'ed7', name: 'T-Shirts' }, { id: 'ed8', name: 'Hemden' }, + { id: 'ed9', name: 'Hosen' }, { id: 'ed10', name: 'Jacken & Mäntel' }, + { id: 'ed11', name: 'Pullover & Hoodies' }, + ]}, + { id: 'ed-schuhe', name: 'Schuhe', children: [ + { id: 'ed12', name: 'Sneaker' }, { id: 'ed13', name: 'Stiefel' }, + { id: 'ed14', name: 'Sandalen' }, { id: 'ed15', name: 'Businessschuhe' }, + ]}, + { id: 'ed-acc', name: 'Accessoires', children: [ + { id: 'ed16', name: 'Uhren' }, { id: 'ed17', name: 'Schmuck' }, + { id: 'ed18', name: 'Taschen' }, { id: 'ed19', name: 'Sonnenbrillen' }, + ]}, + ]}, + { id: 'computer', name: 'Computer, Tablets & Netzwerk', children: [ + { id: 'ed-pc', name: 'Desktops & All-in-Ones', children: [ + { id: 'ed20', name: 'PC-Systeme' }, { id: 'ed21', name: 'Komponenten' }, + ]}, + { id: 'ed-nb', name: 'Notebooks & Tablets', children: [ + { id: 'ed22', name: 'Notebooks' }, { id: 'ed23', name: 'Tablets' }, + ]}, + { id: 'ed-nw', name: 'Netzwerk', children: [ + { id: 'ed24', name: 'Router & Modems' }, { id: 'ed25', name: 'NAS-Server' }, + { id: 'ed26', name: 'Switches & Hubs' }, + ]}, + { id: 'ed-zubehoer', name: 'Zubehör', children: [ + { id: 'ed27', name: 'Tastaturen & Mäuse' }, { id: 'ed28', name: 'Monitore' }, + { id: 'ed29', name: 'Speicher' }, { id: 'ed30', name: 'Kabel & Adapter' }, + ]}, + ]}, + { id: 'handy', name: 'Handys & Kommunikation', children: [ + { id: 'ed-phones', name: 'Smartphones', children: [ + { id: 'ed31', name: 'Apple iPhone' }, { id: 'ed32', name: 'Samsung Galaxy' }, + { id: 'ed33', name: 'Sonstige' }, + ]}, + { id: 'ed-phzub', name: 'Zubehör', children: [ + { id: 'ed34', name: 'Hüllen & Cover' }, { id: 'ed35', name: 'Ladegeräte' }, + { id: 'ed36', name: 'Displayschutz' }, + ]}, + ]}, + { id: 'haus', name: 'Möbel & Wohnen', children: [ + { id: 'ed-moebel', name: 'Möbel', children: [ + { id: 'ed40', name: 'Tische' }, { id: 'ed41', name: 'Stühle' }, + { id: 'ed42', name: 'Regale' }, { id: 'ed43', name: 'Betten' }, + ]}, + { id: 'ed-deko', name: 'Dekoration', children: [ + { id: 'ed44', name: 'Bilder' }, { id: 'ed45', name: 'Kerzen' }, { id: 'ed46', name: 'Vasen' }, + ]}, + ]}, + { id: 'sport', name: 'Sporting Goods', children: [ + { id: 'ed-fitness', name: 'Fitness', children: [ + { id: 'ed50', name: 'Fitnessgeräte' }, { id: 'ed51', name: 'Laufbänder' }, + ]}, + { id: 'ed-outdoor', name: 'Camping & Outdoor', children: [ + { id: 'ed52', name: 'Zelte' }, { id: 'ed53', name: 'Rucksäcke' }, + ]}, + ]}, + ], + + etsy: [ + { id: 'jewel', name: 'Schmuck', children: [ + { id: 'et-rings', name: 'Ringe' }, { id: 'et-necklaces', name: 'Ketten' }, + { id: 'et-earrings', name: 'Ohrringe' }, { id: 'et-bracelets', name: 'Armbänder' }, + ]}, + { id: 'clothing', name: 'Kleidung', children: [ + { id: 'et-dresses', name: 'Kleider' }, { id: 'et-tops', name: 'Tops & Shirts' }, + { id: 'et-outerwear', name: 'Jacken & Mäntel' }, { id: 'et-pants', name: 'Hosen' }, + { id: 'et-shoes', name: 'Schuhe' }, { id: 'et-accessories', name: 'Accessoires' }, + ]}, + { id: 'home', name: 'Haus & Wohnen', children: [ + { id: 'et-deco', name: 'Wanddeko & Kunst' }, { id: 'et-furniture', name: 'Möbel' }, + { id: 'et-kitchen', name: 'Küche & Essen' }, { id: 'et-textiles', name: 'Textilien' }, + { id: 'et-candles', name: 'Kerzen & Duft' }, + ]}, + { id: 'vintage', name: 'Vintage', children: [ + { id: 'et-vclothing', name: 'Vintage Kleidung' }, { id: 'et-vjewelry', name: 'Vintage Schmuck' }, + { id: 'et-vhome', name: 'Vintage Haus & Wohnen' }, { id: 'et-vcollect', name: 'Sammlerstücke' }, + ]}, + { id: 'craft', name: 'Bastelmaterial', children: [ + { id: 'et-fabric', name: 'Stoffe' }, { id: 'et-beads', name: 'Perlen & Zubehör' }, + { id: 'et-tools', name: 'Werkzeug' }, { id: 'et-patterns', name: 'Schnittmuster' }, + ]}, + { id: 'art', name: 'Kunst', children: [ + { id: 'et-prints', name: 'Drucke & Poster' }, { id: 'et-painting', name: 'Gemälde' }, + { id: 'et-photo', name: 'Fotografie' }, { id: 'et-sculpture', name: 'Skulpturen' }, + ]}, + { id: 'gifts', name: 'Geschenke', children: [ + { id: 'et-personal', name: 'Personalisiert' }, { id: 'et-party', name: 'Party & Feier' }, + ]}, + { id: 'bags', name: 'Taschen & Geldbörsen', children: [ + { id: 'et-handbags', name: 'Handtaschen' }, { id: 'et-backpacks', name: 'Rucksäcke' }, + { id: 'et-wallets', name: 'Geldbörsen' }, + ]}, + { id: 'electronics', name: 'Elektronik & Zubehör', children: [ + { id: 'et-phonecases', name: 'Handyhüllen' }, { id: 'et-laptopacc', name: 'Laptop-Zubehör' }, + { id: 'et-gadgets', name: 'Gadgets' }, + ]}, + ], +}; + +function flattenTree(tree, path) { + const result = []; + for (const node of tree) { + const currentPath = [...(path || []), node.name]; + if (node.children) { + for (const child of node.children) { + const childPath = [...currentPath, child.name]; + if (child.children) { + for (const leaf of child.children) { + result.push({ + id: leaf.id, name: leaf.name, + path: [...childPath, leaf.name], + pathStr: [...childPath, leaf.name].join(' > '), + search: [...childPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: child.id, name: child.name, + path: childPath, + pathStr: childPath.join(' > '), + search: childPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +const flatCache = {}; + +export function getCategories(platformId) { + return CATEGORIES[platformId] || []; +} + +export function getCategoriesFlat(platformId) { + if (!flatCache[platformId]) { + flatCache[platformId] = flattenTree(CATEGORIES[platformId] || []); + } + return flatCache[platformId]; +} + +export function matchCategories(platformId, query) { + if (!query) return []; + const flat = getCategoriesFlat(platformId); + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = flat.map(cat => { + let score = 0; + if (cat.search.includes(q)) score += 100; + if (cat.name.toLowerCase() === q) score += 200; + for (const w of words) { + if (w.length < 2) continue; + if (cat.search.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} + +export function getAllPlatformIds() { + return Object.keys(CATEGORIES); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/db.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/db.mjs new file mode 100644 index 0000000..1a2f3dd --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/db.mjs @@ -0,0 +1,994 @@ +import Database from 'better-sqlite3'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dbPath = join(__dirname, '..', 'db', 'vendoo.db'); +const db = new Database(dbPath); +export { db }; + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = readFileSync(join(__dirname, '..', 'db', 'schema.sql'), 'utf-8'); +db.exec(schema); + +const MIGRATIONS = [ + ['brand', 'TEXT'], ['category', 'TEXT'], ['size', 'TEXT'], ['color', 'TEXT'], + ['condition', 'TEXT'], ['price', 'REAL'], ['status', "TEXT DEFAULT 'active'"], ['language', "TEXT DEFAULT 'de'"], + ['sku', 'TEXT'], ['deleted_at', 'TEXT'], ['storage_location', 'TEXT'], ['description_html', 'TEXT'], +]; + +for (const [col, type] of MIGRATIONS) { + try { db.exec(`ALTER TABLE listings ADD COLUMN ${col} ${type}`); } catch {} +} +try { db.exec(`CREATE TABLE IF NOT EXISTS templates (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, platform TEXT, seller_notes TEXT, language TEXT DEFAULT 'de', default_tags TEXT, created_at TEXT DEFAULT (datetime('now')))`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at)`); +} catch {} + + +try { + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + provider TEXT, + provider_model TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT + )`); + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + provider TEXT, + provider_model TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id)`); + +db.exec(`CREATE TABLE IF NOT EXISTS ai_model_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + mode TEXT NOT NULL DEFAULT 'model', + preset TEXT NOT NULL DEFAULT 'mixed', + variants INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'queued', + progress INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + payload_json TEXT, + generation_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_status ON ai_model_jobs(status)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_listing ON ai_model_jobs(listing_id)`); +} catch {} + +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN mode TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN mode TEXT`); } catch {} + +const defaultSettingStmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); +[ + ['ai_model_photos_enabled', '1'], + ['ai_model_photo_default_variants', '3'], + ['ai_model_photo_default_preset', 'mixed'], + ['ai_model_photo_block_underwear', '1'], + ['ai_model_photo_block_kids', '1'], + ['ai_model_photo_provider', 'auto'], + ['ai_model_photo_openrouter_free_only', '1'], + ['ai_model_photo_openrouter_model', ''], + ['ai_model_photo_openai_model', 'gpt-image-1'], + ['ai_model_photo_default_mode', 'model'], +].forEach(entry => { try { defaultSettingStmt.run(...entry); } catch {} }); + +// Backfill SKUs for existing listings +const noSku = db.prepare("SELECT id FROM listings WHERE sku IS NULL ORDER BY id").all(); +if (noSku.length) { + const upd = db.prepare("UPDATE listings SET sku = ? WHERE id = ?"); + for (const row of noSku) { + upd.run(`VD-${String(row.id).padStart(4, '0')}`, row.id); + } +} + +function parseRow(row) { + if (!row) return null; + row.tags = JSON.parse(row.tags || '[]'); + row.photos = JSON.parse(row.photos || '[]'); + return row; +} + +function generateSku() { + const row = db.prepare("SELECT MAX(CAST(SUBSTR(sku, 4) AS INTEGER)) as maxNum FROM listings WHERE sku LIKE 'VD-%'").get(); + const next = (row?.maxNum || 0) + 1; + return `VD-${String(next).padStart(4, '0')}`; +} + +export function createListing(data) { + const sku = data.sku || generateSku(); + const stmt = db.prepare(` + INSERT INTO listings (platform, ai_provider, title, description, description_html, tags, photos, seller_notes, brand, category, size, color, condition, price, status, language, sku, storage_location) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.platform, data.ai_provider, data.title, data.description, + data.description_html || null, + JSON.stringify(Array.isArray(data.tags) ? data.tags : []), + JSON.stringify(Array.isArray(data.photos) ? data.photos : []), + data.seller_notes || null, + data.brand || null, data.category || null, data.size || null, data.color || null, + data.condition || null, data.price || null, data.status || 'active', data.language || 'de', sku, + data.storage_location || null + ); + return getListing(result.lastInsertRowid); +} + +export function getListing(id) { + return parseRow(db.prepare('SELECT * FROM listings WHERE id = ?').get(id)); +} + +export function getListings(query, platform, status) { + let sql = 'SELECT * FROM listings WHERE deleted_at IS NULL'; + const params = []; + if (query) { sql += ' AND (title LIKE ? OR description LIKE ? OR brand LIKE ?)'; const like = `%${query}%`; params.push(like, like, like); } + if (platform) { sql += ' AND platform = ?'; params.push(platform); } + if (status) { sql += ' AND status = ?'; params.push(status); } + sql += ' ORDER BY created_at DESC'; + return db.prepare(sql).all(...params).map(parseRow); +} + +const LISTING_UPDATE_FIELDS = new Set([ + 'platform', 'ai_provider', 'title', 'description', 'description_html', 'tags', 'photos', + 'seller_notes', 'brand', 'category', 'size', 'color', 'condition', 'price', 'status', + 'language', 'sku', 'storage_location', +]); + +export function updateListing(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data || {})) { + if (!LISTING_UPDATE_FIELDS.has(key) || val === undefined) continue; + fields.push(`${key} = ?`); + values.push(['tags', 'photos'].includes(key) ? JSON.stringify(Array.isArray(val) ? val : []) : val); + } + if (!fields.length) return getListing(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE listings SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getListing(id); +} + +export function deleteListing(id) { + return softDeleteListing(id); +} + +export function softDeleteListing(id) { + return db.prepare("UPDATE listings SET deleted_at = datetime('now') WHERE id = ?").run(id); +} + +export function restoreListing(id) { + return db.prepare("UPDATE listings SET deleted_at = NULL WHERE id = ?").run(id); +} + +export function getDeletedListings() { + return db.prepare("SELECT * FROM listings WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").all().map(parseRow); +} + +export function permanentlyDeleteListing(id) { + return db.prepare('DELETE FROM listings WHERE id = ?').run(id); +} + +export function cleanupTrash(days = 30) { + return db.prepare("DELETE FROM listings WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-' || ? || ' days')").run(days); +} + +export function getTrashCount() { + const row = db.prepare("SELECT COUNT(*) as count FROM listings WHERE deleted_at IS NOT NULL").get(); + return row?.count || 0; +} + +export function duplicateListing(id) { + const original = getListing(id); + if (!original) return null; + const data = { + platform: original.platform, + ai_provider: original.ai_provider, + title: (original.title || '') + ' (Kopie)', + description: original.description, + description_html: original.description_html, + tags: original.tags, + photos: original.photos, + seller_notes: original.seller_notes, + brand: original.brand, + category: original.category, + size: original.size, + color: original.color, + condition: original.condition, + price: original.price, + status: 'active', + language: original.language, + storage_location: original.storage_location, + }; + return createListing(data); +} + + +function parseAiModelGeneration(row) { + if (!row) return null; + try { row.source_photos = JSON.parse(row.source_photos || '[]'); } catch { row.source_photos = []; } + try { row.generated_photos = JSON.parse(row.generated_photos || '[]'); } catch { row.generated_photos = []; } + return row; +} + +function parseAiModelPhoto(row) { + if (!row) return null; + return row; +} + +export function createAiModelGeneration(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_generations (listing_id, context, mode, preset, variants, source_photos, generated_photos, prompt_summary, provider, provider_model, moderation_notes, safety_status, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 3, + JSON.stringify(Array.isArray(data.source_photos) ? data.source_photos : []), + JSON.stringify(Array.isArray(data.generated_photos) ? data.generated_photos : []), + data.prompt_summary || null, + data.provider || null, + data.provider_model || null, + data.moderation_notes || null, + data.safety_status || 'pending', + data.status || 'pending' + ); + return getAiModelGeneration(result.lastInsertRowid); +} + +export function updateAiModelGeneration(id, data = {}) { + const allowed = new Set(['listing_id', 'context', 'mode', 'preset', 'variants', 'source_photos', 'generated_photos', 'prompt_summary', 'provider', 'provider_model', 'moderation_notes', 'safety_status', 'status']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(['source_photos', 'generated_photos'].includes(key) ? JSON.stringify(Array.isArray(value) ? value : []) : value); + } + if (!fields.length) return getAiModelGeneration(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_generations SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelGeneration(id); +} + +export function getAiModelGeneration(id) { + return parseAiModelGeneration(db.prepare('SELECT * FROM ai_model_generations WHERE id = ?').get(id)); +} + +export function createAiModelPhoto(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_photos (generation_id, listing_id, variant_index, mode, preset, image_path, source_photo, prompt, provider, provider_model, moderation_result, safety_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.generation_id, + data.listing_id || null, + data.variant_index || 1, + data.mode || 'model', + data.preset || 'mixed', + data.image_path, + data.source_photo || null, + data.prompt || null, + data.provider || null, + data.provider_model || null, + data.moderation_result || null, + data.safety_status || 'pending' + ); + return getAiModelPhoto(result.lastInsertRowid); +} + +export function getAiModelPhoto(id) { + return parseAiModelPhoto(db.prepare('SELECT * FROM ai_model_photos WHERE id = ?').get(id)); +} + +export function getAiModelPhotosForListing(listingId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE listing_id = ? ORDER BY created_at DESC, variant_index ASC').all(listingId).map(parseAiModelPhoto); +} + +export function getAiModelPhotosForGeneration(generationId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE generation_id = ? ORDER BY variant_index ASC').all(generationId).map(parseAiModelPhoto); +} + +export function getAiModelGenerationsForListing(listingId) { + return db.prepare("SELECT * FROM ai_model_generations WHERE listing_id = ? ORDER BY created_at DESC, id DESC").all(listingId).map(parseAiModelGeneration); +} + +export function createAiModelJob(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_jobs (listing_id, context, mode, preset, variants, status, progress, error_message, payload_json, generation_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 1, + data.status || 'queued', + data.progress ?? 0, + data.error_message || null, + JSON.stringify(data.payload_json || {}), + data.generation_id || null + ); + return getAiModelJob(result.lastInsertRowid); +} + +export function getAiModelJob(id) { + const row = db.prepare('SELECT * FROM ai_model_jobs WHERE id = ?').get(id); + if (!row) return null; + try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } + return row; +} + +export function updateAiModelJob(id, data = {}) { + const allowed = new Set(['listing_id','context','mode','preset','variants','status','progress','error_message','payload_json','generation_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(key === 'payload_json' ? JSON.stringify(value || {}) : value); + } + if (!fields.length) return getAiModelJob(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_jobs SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelJob(id); +} + +export function getPendingAiModelJobs(limit = 10) { + return db.prepare("SELECT * FROM ai_model_jobs WHERE status IN ('queued','retry') ORDER BY id ASC LIMIT ?").all(limit).map(row => { try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } return row; }); +} + +export function getSettings() { + const rows = db.prepare('SELECT key, value FROM settings').all(); + const settings = {}; + for (const row of rows) settings[row.key] = row.value; + return settings; +} + +export function updateSettings(settings) { + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + const transaction = db.transaction((entries) => { + for (const [key, value] of entries) stmt.run(key, value); + }); + transaction(Object.entries(settings)); + return getSettings(); +} + + +export function createMobileUploadSession(data) { + const stmt = db.prepare(` + INSERT INTO mobile_upload_sessions (token_hash, user_id, listing_id, context, files, status, expires_at) + VALUES (?, ?, ?, ?, '[]', 'active', ?) + `); + const result = stmt.run( + data.token_hash, + data.user_id || null, + data.listing_id || null, + data.context || 'generator', + data.expires_at + ); + return getMobileUploadSessionById(result.lastInsertRowid, data.user_id || null); +} + +function parseMobileUploadSession(row) { + if (!row) return null; + try { row.files = JSON.parse(row.files || '[]'); } catch { row.files = []; } + return row; +} + +export function getMobileUploadSessionById(id, userId = null) { + const row = userId + ? db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').get(id, userId) + : db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ?').get(id); + return parseMobileUploadSession(row); +} + +export function getMobileUploadSessionByTokenHash(tokenHash) { + return parseMobileUploadSession(db.prepare('SELECT * FROM mobile_upload_sessions WHERE token_hash = ?').get(tokenHash)); +} + +export function appendMobileUploadFiles(id, filenames) { + const current = getMobileUploadSessionById(id); + if (!current) return null; + const merged = [...new Set([...(current.files || []), ...(filenames || [])])].slice(0, 10); + db.prepare("UPDATE mobile_upload_sessions SET files = ?, updated_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(merged), id); + return getMobileUploadSessionById(id); +} + +export function updateMobileUploadSession(id, patch = {}) { + const allowed = new Set(['status', 'listing_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(patch)) { + if (!allowed.has(key)) continue; + fields.push(`${key} = ?`); + values.push(value); + } + if (!fields.length) return getMobileUploadSessionById(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE mobile_upload_sessions SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getMobileUploadSessionById(id); +} + +export function deleteMobileUploadSession(id, userId = null) { + if (userId) return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').run(id, userId); + return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ?').run(id); +} + +export function cleanupMobileUploadSessions() { + return db.prepare("DELETE FROM mobile_upload_sessions WHERE expires_at < datetime('now') OR status = 'closed'").run(); +} + +// --- Seed example templates --- +const tplCount = db.prepare("SELECT COUNT(*) as c FROM templates").get(); +if (tplCount.c === 0) { + const seedTpl = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + seedTpl.run('Sneaker & Schuhe', 'vinted', + 'Originalverpackung vorhanden. Versand mit DHL, gut gepolstert. Tierfreier Nichtraucherhaushalt. Bei Fragen gerne melden!', + 'de', JSON.stringify(['#sneaker', '#schuhe', '#nike', '#adidas', '#secondhand'])); + seedTpl.run('Vintage Kleidung', 'vinted', + 'Vintage-Stück in gepflegtem Zustand. Maße siehe Beschreibung. Versand innerhalb von 1-2 Werktagen. Privatverkauf, keine Rücknahme.', + 'de', JSON.stringify(['#vintage', '#retro', '#secondhand', '#fashion', '#preloved'])); + seedTpl.run('Elektronik & Zubehör', 'ebay-de', + 'Funktioniert einwandfrei, wurde getestet. Originalzubehör dabei, sofern nicht anders angegeben. Versand als versichertes Paket.', + 'de', JSON.stringify(['Elektronik', 'Technik', 'Zubehör', 'gebraucht', 'getestet'])); +} + +// --- Templates --- +export function createTemplate(data) { + const stmt = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + const result = stmt.run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || [])); + return getTemplate(result.lastInsertRowid); +} + +export function getTemplate(id) { + const row = db.prepare('SELECT * FROM templates WHERE id = ?').get(id); + if (!row) return null; + row.default_tags = JSON.parse(row.default_tags || '[]'); + return row; +} + +export function getTemplates() { + return db.prepare('SELECT * FROM templates ORDER BY name').all().map(r => { + r.default_tags = JSON.parse(r.default_tags || '[]'); + return r; + }); +} + +export function updateTemplate(id, data) { + db.prepare('UPDATE templates SET name = ?, platform = ?, seller_notes = ?, language = ?, default_tags = ? WHERE id = ?') + .run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || []), id); + return getTemplate(id); +} + +export function deleteTemplate(id) { + return db.prepare('DELETE FROM templates WHERE id = ?').run(id); +} + +// --- Publish Log --- +try { db.exec(`CREATE TABLE IF NOT EXISTS publish_log (id INTEGER PRIMARY KEY AUTOINCREMENT, listing_id INTEGER NOT NULL, platform TEXT NOT NULL, status TEXT DEFAULT 'pending', external_id TEXT, external_url TEXT, published_at TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE)`); } catch {} + +export function createPublishLog(listingId, platform, status, externalId, externalUrl) { + const stmt = db.prepare('INSERT INTO publish_log (listing_id, platform, status, external_id, external_url, published_at) VALUES (?, ?, ?, ?, ?, datetime(?))'); + stmt.run(listingId, platform, status || 'pending', externalId || null, externalUrl || null, status === 'published' ? 'now' : null); +} + +export function updatePublishLog(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + if (data.status === 'published') { fields.push("published_at = datetime('now')"); } + values.push(id); + db.prepare(`UPDATE publish_log SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function getPublishLogs(listingId) { + return db.prepare('SELECT * FROM publish_log WHERE listing_id = ? ORDER BY created_at DESC').all(listingId); +} + +export function getPublishStatus(listingIds) { + if (!listingIds.length) return {}; + const placeholders = listingIds.map(() => '?').join(','); + const rows = db.prepare(`SELECT listing_id, platform, status FROM publish_log WHERE listing_id IN (${placeholders})`).all(...listingIds); + const result = {}; + for (const r of rows) { + if (!result[r.listing_id]) result[r.listing_id] = {}; + result[r.listing_id][r.platform] = r.status; + } + return result; +} + +// --- eBay Queue --- +try { db.exec(`CREATE TABLE IF NOT EXISTS ebay_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + status TEXT DEFAULT 'pending', + ebay_item_id TEXT, + ebay_url TEXT, + error TEXT, + retries INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function addToEbayQueue(listingIds) { + const stmt = db.prepare('INSERT OR IGNORE INTO ebay_queue (listing_id) VALUES (?)'); + const existing = db.prepare('SELECT listing_id FROM ebay_queue WHERE listing_id = ? AND status IN (?, ?)'); + const added = []; + for (const id of listingIds) { + const ex = existing.get(id, 'pending', 'uploading'); + if (!ex) { + stmt.run(id); + added.push(id); + } + } + return added; +} + +export function getEbayQueue(status) { + let sql = 'SELECT q.*, l.title, l.sku, l.platform FROM ebay_queue q JOIN listings l ON q.listing_id = l.id'; + if (status) { sql += ' WHERE q.status = ?'; return db.prepare(sql + ' ORDER BY q.created_at').all(status); } + return db.prepare(sql + ' ORDER BY q.created_at DESC').all(); +} + +export function updateEbayQueue(id, data) { + const fields = ["updated_at = datetime('now')"]; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + values.push(id); + db.prepare(`UPDATE ebay_queue SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeFromEbayQueue(id) { + db.prepare('DELETE FROM ebay_queue WHERE id = ?').run(id); +} + +export function clearEbayQueue(status) { + if (status) db.prepare('DELETE FROM ebay_queue WHERE status = ?').run(status); + else db.prepare('DELETE FROM ebay_queue').run(); +} + +// --- Scheduled Publishes --- +try { db.exec(`CREATE TABLE IF NOT EXISTS scheduled_publish ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + scheduled_at TEXT NOT NULL, + status TEXT DEFAULT 'scheduled', + error TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function schedulePublish(listingId, platform, scheduledAt) { + const existing = db.prepare('SELECT id FROM scheduled_publish WHERE listing_id = ? AND platform = ? AND status = ?').get(listingId, platform, 'scheduled'); + if (existing) { + db.prepare('UPDATE scheduled_publish SET scheduled_at = ? WHERE id = ?').run(scheduledAt, existing.id); + return existing.id; + } + const r = db.prepare('INSERT INTO scheduled_publish (listing_id, platform, scheduled_at) VALUES (?, ?, ?)').run(listingId, platform, scheduledAt); + return r.lastInsertRowid; +} + +export function getScheduledPublishes(status) { + let sql = 'SELECT s.*, l.title, l.sku, l.price FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id'; + if (status) { sql += ' WHERE s.status = ?'; return db.prepare(sql + ' ORDER BY s.scheduled_at').all(status); } + return db.prepare(sql + ' ORDER BY s.scheduled_at').all(); +} + +export function getDueScheduled() { + return db.prepare("SELECT s.*, l.title, l.sku, l.platform as listing_platform FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id WHERE s.status = 'scheduled' AND s.scheduled_at <= datetime('now') ORDER BY s.scheduled_at").all(); +} + +export function updateScheduledPublish(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + values.push(id); + db.prepare(`UPDATE scheduled_publish SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeScheduledPublish(id) { + db.prepare('DELETE FROM scheduled_publish WHERE id = ?').run(id); +} + +// --- Inventory / Storage --- +export function getStorageLocations() { + return db.prepare(` + SELECT storage_location, COUNT(*) as count, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'sold' THEN 1 ELSE 0 END) as sold, + SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END) as reserved, + COALESCE(SUM(CASE WHEN status = 'active' THEN price ELSE 0 END), 0) as total_value + FROM listings + WHERE deleted_at IS NULL AND storage_location IS NOT NULL AND storage_location != '' + GROUP BY storage_location ORDER BY storage_location + `).all(); +} + +export function getListingsByLocation(location) { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND storage_location = ? ORDER BY created_at DESC" + ).all(location).map(parseRow); +} + +export function getUnassignedListings() { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND (storage_location IS NULL OR storage_location = '') ORDER BY created_at DESC" + ).all().map(parseRow); +} + +export function bulkUpdateLocation(ids, location) { + const stmt = db.prepare("UPDATE listings SET storage_location = ?, updated_at = datetime('now') WHERE id = ?"); + const tx = db.transaction(() => { for (const id of ids) stmt.run(location, id); }); + tx(); + return ids.length; +} + +// --- CSV Export --- +export function getListingsForExport(platform, status) { + return getListings(null, platform, status); +} + +// --- Studio Flow Dashboard --- +function listingCompleteness(listing) { + const checks = [ + [listing.title, 14], + [listing.description, 16], + [Array.isArray(listing.photos) && listing.photos.length > 0, 18], + [listing.brand, 8], + [listing.category, 12], + [listing.condition, 10], + [Number(listing.price) > 0, 10], + [listing.size, 4], + [listing.color, 4], + [Array.isArray(listing.tags) && listing.tags.length > 0, 4], + ]; + return checks.reduce((score, [value, weight]) => score + (value ? weight : 0), 0); +} + +function sqliteDateToIso(value) { + if (!value) return null; + if (value.includes('T')) return value; + return `${value.replace(' ', 'T')}Z`; +} + +export function getDashboardWorkflow(limitPerStage = 3) { + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare("SELECT * FROM scheduled_publish WHERE status = 'scheduled' ORDER BY scheduled_at").all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + + const latestPublish = new Map(); + for (const row of publishRows) { + if (!latestPublish.has(row.listing_id)) latestPublish.set(row.listing_id, row); + } + const activeSchedule = new Map(scheduleRows.map(row => [row.listing_id, row])); + const latestQueue = new Map(); + for (const row of queueRows) { + if (!latestQueue.has(row.listing_id)) latestQueue.set(row.listing_id, row); + } + + const stages = { + captured: [], + ai_review: [], + ready: [], + scheduled: [], + published: [], + sold: [], + }; + + const itemById = new Map(); + for (const listing of listings) { + const completeness = listingCompleteness(listing); + const publish = latestPublish.get(listing.id) || null; + const schedule = activeSchedule.get(listing.id) || null; + const queue = latestQueue.get(listing.id) || null; + + let stage = 'ready'; + if (listing.status === 'sold') stage = 'sold'; + else if (schedule) stage = 'scheduled'; + else if (publish?.status === 'published' || queue?.status === 'published') stage = 'published'; + else if (completeness < 55) stage = 'captured'; + else if (completeness < 85) stage = 'ai_review'; + + const item = { + id: listing.id, + title: listing.title || 'Unbenanntes Listing', + sku: listing.sku || `VD-${String(listing.id).padStart(4, '0')}`, + platform: listing.platform, + aiProvider: listing.ai_provider, + photos: listing.photos || [], + price: listing.price, + status: listing.status, + storageLocation: listing.storage_location, + brand: listing.brand, + category: listing.category, + size: listing.size, + color: listing.color, + condition: listing.condition, + completeness, + stage, + scheduledAt: schedule?.scheduled_at || null, + publishStatus: publish?.status || queue?.status || null, + queueStatus: queue?.status || null, + createdAt: listing.created_at, + updatedAt: listing.updated_at, + }; + stages[stage].push(item); + itemById.set(listing.id, item); + } + + const attention = []; + const addAttention = (listingId, type, message, action, severity = 'warning') => { + const item = itemById.get(listingId); + if (!item) return; + attention.push({ + id: `${listingId}:${type}`, + listingId, + type, + message, + action, + severity, + title: item.title, + sku: item.sku, + photo: item.photos?.[0] || null, + createdAt: item.updatedAt || item.createdAt, + }); + }; + + for (const row of queueRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', row.error || 'eBay-Veröffentlichung fehlgeschlagen', 'Erneut versuchen', 'danger'); + } + for (const row of publishRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', 'Veröffentlichung fehlgeschlagen', 'Prüfen', 'danger'); + } + for (const item of itemById.values()) { + if (!Number(item.price)) addAttention(item.id, 'missing_price', 'Preis fehlt', 'Preis setzen'); + if (!item.storageLocation && item.status !== 'sold') addAttention(item.id, 'missing_location', 'Lagerort fehlt', 'Lagerort setzen'); + if (item.completeness < 85) addAttention(item.id, 'low_confidence', `Listing-Qualität ${item.completeness}%`, 'Prüfen'); + } + + const attentionPriority = { danger: 0, warning: 1, info: 2 }; + attention.sort((a, b) => { + const severity = (attentionPriority[a.severity] ?? 9) - (attentionPriority[b.severity] ?? 9); + if (severity) return severity; + return String(b.createdAt || '').localeCompare(String(a.createdAt || '')); + }); + + const activity = []; + for (const listing of listings.slice(0, 12)) { + activity.push({ + type: listing.status === 'sold' ? 'sold' : 'created', + listingId: listing.id, + text: listing.status === 'sold' + ? `${listing.title || listing.sku} wurde als verkauft markiert` + : `${listing.title || listing.sku} wurde erfasst`, + at: sqliteDateToIso(listing.updated_at || listing.created_at), + }); + } + for (const row of publishRows.slice(0, 12)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + const verbs = { published: 'veröffentlicht', failed: 'konnte nicht veröffentlicht werden', copied: 'für Smart Copy vorbereitet', pending: 'zur Veröffentlichung vorgemerkt' }; + activity.push({ + type: row.status || 'published', + listingId: row.listing_id, + text: `${item.title} wurde auf ${row.platform} ${verbs[row.status] || row.status}`, + at: sqliteDateToIso(row.published_at || row.created_at), + }); + } + for (const row of scheduleRows.slice(0, 8)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + activity.push({ + type: 'scheduled', + listingId: row.listing_id, + text: `${item.title} wurde für ${row.platform} geplant`, + at: sqliteDateToIso(row.created_at), + }); + } + activity.sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))); + + const orderedStages = {}; + for (const [key, items] of Object.entries(stages)) { + orderedStages[key] = { + count: items.length, + items: items.slice(0, Math.max(1, Number(limitPerStage) || 3)), + }; + } + + return { + stages: orderedStages, + attention: attention.slice(0, 6), + attentionCount: attention.length, + activity: activity.slice(0, 8), + totals: { + listings: listings.length, + active: listings.filter(item => item.status === 'active').length, + sold: listings.filter(item => item.status === 'sold').length, + inventoryValue: listings.reduce((sum, item) => sum + (item.status !== 'sold' ? Number(item.price) || 0 : 0), 0), + }, + }; +} + +export function getDashboardAnalytics(days = 30) { + const periodDays = Math.min(Math.max(Number(days) || 30, 7), 365); + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare('SELECT * FROM scheduled_publish ORDER BY created_at DESC').all(); + const locations = getStorageLocations(); + + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - (periodDays - 1)); + const weekStart = new Date(now); + weekStart.setDate(weekStart.getDate() - 7); + const todayKey = now.toISOString().slice(0, 10); + + const soldListings = listings.filter(item => item.status === 'sold'); + const dateOf = item => { + const raw = item.updated_at || item.created_at; + if (!raw) return null; + const parsed = new Date(String(raw).replace(' ', 'T') + (String(raw).includes('Z') ? '' : 'Z')); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const timelineMap = new Map(); + for (let index = 0; index < periodDays; index++) { + const date = new Date(start); + date.setDate(start.getDate() + index); + const key = date.toISOString().slice(0, 10); + timelineMap.set(key, { date: key, revenue: 0, sales: 0 }); + } + + const platformRevenue = new Map(); + const categoryRevenue = new Map(); + for (const listing of soldListings) { + const date = dateOf(listing); + const price = Number(listing.price) || 0; + if (date) { + const key = date.toISOString().slice(0, 10); + const bucket = timelineMap.get(key); + if (bucket) { + bucket.revenue += price; + bucket.sales += 1; + } + } + const platform = listing.platform || 'Sonstige'; + platformRevenue.set(platform, (platformRevenue.get(platform) || 0) + price); + const category = listing.category ? String(listing.category).split('>').pop().trim() : 'Sonstiges'; + categoryRevenue.set(category, (categoryRevenue.get(category) || 0) + price); + } + + const successes = publishRows.filter(row => row.status === 'published').length; + const failures = publishRows.filter(row => row.status === 'failed').length + queueRows.filter(row => row.status === 'failed').length; + const publishAttempts = successes + failures; + const quality = listings.length + ? Math.round(listings.reduce((sum, item) => sum + listingCompleteness(item), 0) / listings.length) + : 0; + + const workflow = getDashboardWorkflow(6); + const recentActivity = workflow.activity.slice(0, 6); + const notifications = workflow.attention.slice(0, 6); + const mobileUploads = db.prepare("SELECT COUNT(*) AS count FROM mobile_upload_sessions WHERE created_at >= datetime('now', '-24 hours')").get()?.count || 0; + + const topSellers = [...soldListings] + .sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)) + .slice(0, 5) + .map(item => ({ + id: item.id, + title: item.title, + sku: item.sku, + platform: item.platform, + revenue: Number(item.price) || 0, + sales: 1, + status: item.status, + photo: item.photos?.[0] || null, + size: item.size, + color: item.color, + })); + + return { + periodDays, + generatedAt: new Date().toISOString(), + kpis: { + revenueToday: soldListings.filter(item => String(item.updated_at || item.created_at || '').startsWith(todayKey)).reduce((sum, item) => sum + (Number(item.price) || 0), 0), + sales7Days: soldListings.filter(item => { const date = dateOf(item); return date && date >= weekStart; }).length, + activeListings: listings.filter(item => !['sold', 'draft'].includes(item.status)).length, + drafts: listings.filter(item => item.status === 'draft').length, + inventoryValue: listings.filter(item => item.status !== 'sold').reduce((sum, item) => sum + (Number(item.price) || 0), 0), + publishSuccessRate: publishAttempts ? Math.round((successes / publishAttempts) * 1000) / 10 : 100, + quality, + }, + timeline: [...timelineMap.values()], + platformRevenue: [...platformRevenue.entries()].map(([platform, revenue]) => ({ platform, revenue })).sort((a, b) => b.revenue - a.revenue), + categoryRevenue: [...categoryRevenue.entries()].map(([category, revenue]) => ({ category, revenue })).sort((a, b) => b.revenue - a.revenue).slice(0, 6), + focus: { + ready: workflow.stages?.ready?.count || 0, + priceChecks: workflow.attention.filter(item => item.type === 'missing_price').length, + failedPublishes: failures, + mobileUploads, + activeLocations: locations.filter(item => Number(item.count) > 0).length, + }, + topSellers, + activity: recentActivity, + notifications, + system: { + healthy: true, + queuePending: queueRows.filter(item => item.status === 'pending').length, + scheduled: scheduleRows.filter(item => item.status === 'scheduled').length, + }, + }; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ebay-api.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ebay-api.mjs new file mode 100644 index 0000000..5b6a979 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/ebay-api.mjs @@ -0,0 +1,272 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const EBAY_AUTH_SANDBOX = 'https://auth.sandbox.ebay.com/oauth2/authorize'; +const EBAY_AUTH_PROD = 'https://auth.ebay.com/oauth2/authorize'; +const EBAY_TOKEN_SANDBOX = 'https://api.sandbox.ebay.com/identity/v1/oauth2/token'; +const EBAY_TOKEN_PROD = 'https://api.ebay.com/identity/v1/oauth2/token'; +const EBAY_API_SANDBOX = 'https://api.sandbox.ebay.com'; +const EBAY_API_PROD = 'https://api.ebay.com'; + +function isSandbox() { + return process.env.EBAY_SANDBOX === 'true'; +} + +function authUrl() { return isSandbox() ? EBAY_AUTH_SANDBOX : EBAY_AUTH_PROD; } +function tokenUrl() { return isSandbox() ? EBAY_TOKEN_SANDBOX : EBAY_TOKEN_PROD; } +function apiBase() { return isSandbox() ? EBAY_API_SANDBOX : EBAY_API_PROD; } + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function getBasicAuth() { + const clientId = process.env.EBAY_CLIENT_ID; + const clientSecret = process.env.EBAY_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new Error('EBAY_CLIENT_ID oder EBAY_CLIENT_SECRET nicht gesetzt'); + return Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); +} + +const SCOPES = [ + 'https://api.ebay.com/oauth/api_scope', + 'https://api.ebay.com/oauth/api_scope/sell.inventory', + 'https://api.ebay.com/oauth/api_scope/sell.account', + 'https://api.ebay.com/oauth/api_scope/sell.fulfillment', + 'https://api.ebay.com/oauth/api_scope/sell.marketing', +].join(' '); + +let oauthState = null; + +export function getOAuthUrl(redirectUri) { + if (!process.env.EBAY_CLIENT_ID) throw new Error('EBAY_CLIENT_ID nicht gesetzt'); + oauthState = Math.random().toString(36).substring(2); + + const params = new URLSearchParams({ + client_id: process.env.EBAY_CLIENT_ID, + response_type: 'code', + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + scope: SCOPES, + state: oauthState, + }); + return `${authUrl()}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + if (oauthState && state !== oauthState) throw new Error('Ungültiger OAuth-State'); + oauthState = null; + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const refreshTkn = process.env.EBAY_REFRESH_TOKEN; + if (!refreshTkn) throw new Error('Kein eBay Refresh-Token'); + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshTkn, + scope: SCOPES, + }), + }); + + if (!res.ok) throw new Error('eBay Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + if (tokens.refresh_token) setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function ebayFetch(path, opts = {}) { + let token = process.env.EBAY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit eBay verbunden'); + + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Content-Language': 'de-DE', + 'X-EBAY-C-MARKETPLACE-ID': 'EBAY_DE', + ...opts.headers, + }; + + let res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + } + + if (res.status === 204) return null; + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay API (${res.status}): ${err}`); + } + + const ct = res.headers.get('content-type') || ''; + return ct.includes('json') ? res.json() : res.text(); +} + +function mapCondition(condition) { + const map = { + 'neu': 'NEW', 'new': 'NEW', + 'wie neu': 'LIKE_NEW', 'like new': 'LIKE_NEW', + 'sehr gut': 'VERY_GOOD', 'very good': 'VERY_GOOD', + 'gut': 'GOOD', 'good': 'GOOD', + 'akzeptabel': 'ACCEPTABLE', 'acceptable': 'ACCEPTABLE', + 'defekt': 'FOR_PARTS_OR_NOT_WORKING', + }; + return map[(condition || '').toLowerCase()] || 'GOOD'; +} + +export async function createInventoryItem(listing, sku) { + const images = (listing.photos || []).map(p => `http://localhost:${process.env.PORT || 8124}/uploads/${p}`); + + const item = { + availability: { + shipToLocationAvailability: { quantity: 1 }, + }, + condition: mapCondition(listing.condition), + product: { + title: listing.title, + description: listing.description || '', + aspects: {}, + imageUrls: images, + }, + }; + + if (listing.brand) item.product.aspects['Marke'] = [listing.brand]; + if (listing.color) item.product.aspects['Farbe'] = [listing.color]; + if (listing.size) item.product.aspects['Größe'] = [listing.size]; + + await ebayFetch(`/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, { + method: 'PUT', + body: JSON.stringify(item), + }); + + return sku; +} + +export async function createOffer(listing, sku) { + const price = listing.price || listing.suggested_price || 1; + + const offer = { + sku, + marketplaceId: 'EBAY_DE', + format: 'FIXED_PRICE', + listingDescription: listing.description || '', + availableQuantity: 1, + pricingSummary: { + price: { value: String(price), currency: 'EUR' }, + }, + listingPolicies: {}, + merchantLocationKey: process.env.EBAY_LOCATION_KEY || undefined, + }; + + const categoryId = listing.ebay_category_id || process.env.EBAY_DEFAULT_CATEGORY || '175673'; + offer.categoryId = categoryId; + + const fulfillmentId = process.env.EBAY_FULFILLMENT_POLICY_ID; + const paymentId = process.env.EBAY_PAYMENT_POLICY_ID; + const returnId = process.env.EBAY_RETURN_POLICY_ID; + + if (fulfillmentId) offer.listingPolicies.fulfillmentPolicyId = fulfillmentId; + if (paymentId) offer.listingPolicies.paymentPolicyId = paymentId; + if (returnId) offer.listingPolicies.returnPolicyId = returnId; + + const result = await ebayFetch('/sell/inventory/v1/offer', { + method: 'POST', + body: JSON.stringify(offer), + }); + + return result; +} + +export async function publishOffer(offerId) { + const result = await ebayFetch(`/sell/inventory/v1/offer/${offerId}/publish`, { + method: 'POST', + }); + return result; +} + +export async function createAndPublish(listing, sku) { + await createInventoryItem(listing, sku); + const offer = await createOffer(listing, sku); + const offerId = offer.offerId; + const published = await publishOffer(offerId); + return { + offerId, + listingId: published.listingId, + url: `https://www.ebay.de/itm/${published.listingId}`, + }; +} + +export async function getFulfillmentPolicies() { + return ebayFetch('/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_DE'); +} + +export async function getPaymentPolicies() { + return ebayFetch('/sell/account/v1/payment_policy?marketplace_id=EBAY_DE'); +} + +export async function getReturnPolicies() { + return ebayFetch('/sell/account/v1/return_policy?marketplace_id=EBAY_DE'); +} + +export async function getPolicies() { + const [fulfillment, payment, returns] = await Promise.all([ + getFulfillmentPolicies().catch(() => ({ fulfillmentPolicies: [] })), + getPaymentPolicies().catch(() => ({ paymentPolicies: [] })), + getReturnPolicies().catch(() => ({ returnPolicies: [] })), + ]); + return { + fulfillment: fulfillment.fulfillmentPolicies || [], + payment: payment.paymentPolicies || [], + returns: returns.returnPolicies || [], + }; +} + +export function isConnected() { + return !!(process.env.EBAY_ACCESS_TOKEN && process.env.EBAY_CLIENT_ID); +} + +export function hasCredentials() { + return !!(process.env.EBAY_CLIENT_ID && process.env.EBAY_CLIENT_SECRET); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/etsy-api.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/etsy-api.mjs new file mode 100644 index 0000000..00c0e99 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/etsy-api.mjs @@ -0,0 +1,184 @@ +import { randomBytes, createHash } from 'crypto'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const ETSY_BASE = 'https://api.etsy.com/v3'; +const ETSY_AUTH = 'https://www.etsy.com/oauth/connect'; +const ETSY_TOKEN = 'https://api.etsy.com/v3/public/oauth/token'; + +let pkceStore = {}; + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function generatePKCE() { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +export function getAuthUrl(redirectUri) { + const apiKey = process.env.ETSY_API_KEY; + if (!apiKey) throw new Error('ETSY_API_KEY nicht gesetzt'); + + const { verifier, challenge } = generatePKCE(); + const state = randomBytes(16).toString('hex'); + pkceStore[state] = verifier; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: apiKey, + redirect_uri: redirectUri, + scope: 'listings_w listings_r shops_r images_w', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + return `${ETSY_AUTH}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + const verifier = pkceStore[state]; + if (!verifier) throw new Error('Ungültiger OAuth-State'); + delete pkceStore[state]; + + const apiKey = process.env.ETSY_API_KEY; + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: apiKey, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const apiKey = process.env.ETSY_API_KEY; + const refreshToken = process.env.ETSY_REFRESH_TOKEN; + if (!refreshToken) throw new Error('Kein Etsy Refresh-Token'); + + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: apiKey, + refresh_token: refreshToken, + }), + }); + + if (!res.ok) throw new Error('Etsy Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function etsyFetch(path, opts = {}) { + let token = process.env.ETSY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit Etsy verbunden'); + + const apiKey = process.env.ETSY_API_KEY; + const headers = { 'x-api-key': apiKey, Authorization: `Bearer ${token}`, ...opts.headers }; + let res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + } + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy API Fehler (${res.status}): ${err}`); + } + return res.json(); +} + +export async function getShopId() { + const data = await etsyFetch('/application/users/me'); + const userId = data.user_id; + const shop = await etsyFetch(`/application/users/${userId}/shops`); + if (!shop.results?.length) throw new Error('Kein Etsy-Shop gefunden'); + return shop.results[0].shop_id; +} + +export async function createEtsyListing(listing, shopId) { + const tags = Array.isArray(listing.tags) ? listing.tags.slice(0, 13) : []; + const price = listing.price || listing.suggested_price || 10; + + const body = { + quantity: 1, + title: listing.title, + description: listing.description, + price: price, + who_made: 'someone_else', + when_made: '2020_2025', + taxonomy_id: 1, + tags, + type: 'physical', + shipping_profile_id: null, + state: 'draft', + }; + + const result = await etsyFetch(`/application/shops/${shopId}/listings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return result; +} + +export async function uploadEtsyImage(shopId, listingId, imageBuffer, filename) { + const formData = new FormData(); + formData.append('image', new Blob([imageBuffer]), filename); + + const token = process.env.ETSY_ACCESS_TOKEN; + const apiKey = process.env.ETSY_API_KEY; + + const res = await fetch(`${ETSY_BASE}/application/shops/${shopId}/listings/${listingId}/images`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Bild-Upload fehlgeschlagen: ${err}`); + } + return res.json(); +} + +export function isConnected() { + return !!(process.env.ETSY_ACCESS_TOKEN && process.env.ETSY_API_KEY); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/fees.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/fees.mjs new file mode 100644 index 0000000..60f7b3b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/fees.mjs @@ -0,0 +1,61 @@ +const PLATFORM_FEES = { + vinted: { + name: 'Vinted', + buyerProtection: 0.05, + sellerFee: 0, + fixedFee: 0.70, + calculate(price) { + const fees = price * this.buyerProtection + this.fixedFee; + return { gross: price, fees: Math.round(fees * 100) / 100, net: Math.round((price - fees) * 100) / 100, breakdown: `${(this.buyerProtection * 100)}% Käuferschutz + ${this.fixedFee}€ fix` }; + }, + }, + 'ebay-ka': { + name: 'eBay Kleinanzeigen', + sellerFee: 0, + fixedFee: 0, + calculate(price) { + return { gross: price, fees: 0, net: price, breakdown: 'Keine Gebühren (Direktverkauf)' }; + }, + }, + 'ebay-de': { + name: 'eBay.de', + finalValueFee: 0.11, + fixedFee: 0.35, + calculate(price) { + const fees = price * this.finalValueFee + this.fixedFee; + return { gross: price, fees: Math.round(fees * 100) / 100, net: Math.round((price - fees) * 100) / 100, breakdown: `11% Verkaufsprovision + 0.35€ fix` }; + }, + }, + etsy: { + name: 'Etsy', + transactionFee: 0.065, + listingFee: 0.20, + paymentProcessing: 0.04, + fixedProcessing: 0.30, + calculate(price) { + const transaction = price * this.transactionFee; + const processing = price * this.paymentProcessing + this.fixedProcessing; + const fees = transaction + processing + this.listingFee; + return { + gross: price, + fees: Math.round(fees * 100) / 100, + net: Math.round((price - fees) * 100) / 100, + breakdown: `6.5% Transaktion + 4% + 0.30€ Zahlung + 0.20€ Listing`, + }; + }, + }, +}; + +export function calculateFees(platform, price) { + const calc = PLATFORM_FEES[platform]; + if (!calc) return { gross: price, fees: 0, net: price, breakdown: 'Unbekannte Plattform' }; + return calc.calculate(price); +} + +export function getAllFees(price) { + const result = {}; + for (const [id, calc] of Object.entries(PLATFORM_FEES)) { + result[id] = calc.calculate(price); + } + return result; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/html-templates.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/html-templates.mjs new file mode 100644 index 0000000..5807ccb --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/html-templates.mjs @@ -0,0 +1,163 @@ +const TEMPLATES = { + vinted: { + name: 'Vinted Classic', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags.join(' ') : listing.tags || ''; + return `
                                              +

                                              ${richDescription(listing)}

                                              + ${listing.brand || listing.size || listing.color ? ` + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                                              Marke${esc(listing.brand)}
                                              Kategorie${esc(listing.category)}
                                              Größe${esc(listing.size)}
                                              Farbe${esc(listing.color)}
                                              Zustand${esc(listing.condition)}
                                              ` : ''} + ${tags ? `

                                              ${esc(tags)}

                                              ` : ''} +
                                              `; + }, + }, + + 'ebay-ka': { + name: 'Kleinanzeigen Modern', + wrap(listing) { + return `
                                              +

                                              ${richDescription(listing)}

                                              + ${listing.brand || listing.size ? `
                                              +

                                              Artikeldetails

                                              +
                                              + ${listing.brand ? `
                                              Marke: ${esc(listing.brand)}
                                              ` : ''} + ${listing.category ? `
                                              Kategorie: ${esc(listing.category)}
                                              ` : ''} + ${listing.size ? `
                                              Größe: ${esc(listing.size)}
                                              ` : ''} + ${listing.color ? `
                                              Farbe: ${esc(listing.color)}
                                              ` : ''} + ${listing.condition ? `
                                              Zustand: ${esc(listing.condition)}
                                              ` : ''} +
                                              +
                                              ` : ''} +
                                              `; + }, + }, + + 'ebay-de': { + name: 'eBay Professional', + wrap(listing) { + return `
                                              +
                                              +

                                              ${esc(listing.title)}

                                              +
                                              +

                                              ${richDescription(listing)}

                                              + ${listing.brand || listing.size ? `
                                              +
                                              Artikelmerkmale
                                              + + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                                              Marke${esc(listing.brand)}
                                              Typ${esc(listing.category)}
                                              Größe${esc(listing.size)}
                                              Farbe${esc(listing.color)}
                                              Zustand${esc(listing.condition)}
                                              +
                                              ` : ''} +

                                              Privatverkauf — keine Garantie, keine Rücknahme.

                                              +
                                              `; + }, + }, + + etsy: { + name: 'Etsy Handmade', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags : []; + return `
                                              +

                                              ${richDescription(listing)}

                                              + ${listing.brand || listing.size ? `
                                              +

                                              Details

                                              +
                                              + ${listing.brand ? `
                                              Brand: ${esc(listing.brand)}
                                              ` : ''} + ${listing.category ? `
                                              Category: ${esc(listing.category)}
                                              ` : ''} + ${listing.size ? `
                                              Size: ${esc(listing.size)}
                                              ` : ''} + ${listing.color ? `
                                              Color: ${esc(listing.color)}
                                              ` : ''} + ${listing.condition ? `
                                              Condition: ${esc(listing.condition)}
                                              ` : ''} +
                                              +
                                              ` : ''} + ${tags.length ? `

                                              ${tags.map(t => `${esc(t)}`).join(' ')}

                                              ` : ''} +
                                              `; + }, + }, +}; + +function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function nl2br(s) { return esc(s || '').replace(/\n/g, '
                                              '); } + +const ALLOWED_RICH_TAGS = new Set([ + 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'ol', 'ul', 'li', + 'blockquote', 'a', 'h1', 'h2', 'h3', 'span', 'div', 'sub', 'sup', 'code', 'pre', +]); +const VOID_RICH_TAGS = new Set(['br']); + +function sanitizeStyle(style) { + const safe = []; + for (const declaration of String(style || '').split(';')) { + const [rawName, ...rest] = declaration.split(':'); + const name = String(rawName || '').trim().toLowerCase(); + const value = rest.join(':').trim(); + if (!value) continue; + if (!['color', 'background-color', 'text-align'].includes(name)) continue; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) continue; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) continue; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) continue; + safe.push(`${name}:${value}`); + } + return safe.join(';'); +} + +export function sanitizeRichHtml(input) { + let html = String(input || ''); + html = html + .replace(//g, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)\b[^>]*\/?\s*>/gi, ''); + + return html.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (full, rawTag, rawAttrs) => { + const tag = String(rawTag || '').toLowerCase(); + const closing = /^<\//.test(full); + if (!ALLOWED_RICH_TAGS.has(tag)) return ''; + if (closing) return VOID_RICH_TAGS.has(tag) ? '' : ``; + + const attrs = []; + const source = String(rawAttrs || ''); + source.replace(/([a-z0-9:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi, (_m, rawName, dq, sq, bare) => { + const name = String(rawName || '').toLowerCase(); + const value = dq ?? sq ?? bare ?? ''; + if (name.startsWith('on')) return ''; + if (name === 'href' && tag === 'a') { + const href = String(value).trim(); + if (/^(https?:|mailto:|tel:|#|\/)/i.test(href)) attrs.push(`href="${esc(href)}"`); + } else if (name === 'target' && tag === 'a' && ['_blank', '_self'].includes(value)) { + attrs.push(`target="${value}"`); + } else if (name === 'rel' && tag === 'a') { + attrs.push('rel="noopener noreferrer"'); + } else if (name === 'style') { + const safeStyle = sanitizeStyle(value); + if (safeStyle) attrs.push(`style="${esc(safeStyle)}"`); + } else if (name === 'class' && /^ql-(align|indent|size|font|color|background)-[a-z0-9-]+$/i.test(value)) { + attrs.push(`class="${esc(value)}"`); + } + return ''; + }); + if (tag === 'a' && attrs.some(attr => attr.startsWith('target="_blank"')) && !attrs.some(attr => attr.startsWith('rel='))) { + attrs.push('rel="noopener noreferrer"'); + } + return `<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''}>`; + }); +} + +function richDescription(listing) { + const html = sanitizeRichHtml(listing?.description_html || ''); + return html || nl2br(listing?.description || ''); +} + +export function wrapInTemplate(listing, platformId) { + const tpl = TEMPLATES[platformId]; + if (!tpl) return `

                                              ${richDescription(listing)}

                                              `; + return tpl.wrap(listing); +} + +export function getHtmlTemplates() { + return Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/images.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/images.mjs new file mode 100644 index 0000000..a86eceb --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/images.mjs @@ -0,0 +1,91 @@ +import sharp from 'sharp'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const uploadsDir = join(__dirname, '..', 'uploads'); + +export async function prepareForAI(filenames, maxSize = 1024) { + const images = []; + for (const filename of filenames) { + const filepath = join(uploadsDir, filename); + const buffer = await sharp(filepath) + .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + images.push({ base64: buffer.toString('base64'), mediaType: 'image/jpeg' }); + } + return images; +} + +export async function removeBackground(filename) { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_nobg.png'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const { data, info } = await image + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const corners = [ + getPixel(data, 0, 0, info.width), + getPixel(data, info.width - 1, 0, info.width), + getPixel(data, 0, info.height - 1, info.width), + getPixel(data, info.width - 1, info.height - 1, info.width), + ]; + + const bgColor = avgColor(corners); + const tolerance = 60; + const result = Buffer.from(data); + + for (let i = 0; i < data.length; i += 4) { + const dr = Math.abs(data[i] - bgColor.r); + const dg = Math.abs(data[i + 1] - bgColor.g); + const db = Math.abs(data[i + 2] - bgColor.b); + if (dr + dg + db < tolerance * 3) { + result[i + 3] = 0; + } + } + + await sharp(result, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png() + .toFile(outPath); + + return newName; +} + +function getPixel(data, x, y, width) { + const idx = (y * width + x) * 4; + return { r: data[idx], g: data[idx + 1], b: data[idx + 2] }; +} + +function avgColor(pixels) { + const sum = pixels.reduce((a, p) => ({ r: a.r + p.r, g: a.g + p.g, b: a.b + p.b }), { r: 0, g: 0, b: 0 }); + const n = pixels.length; + return { r: Math.round(sum.r / n), g: Math.round(sum.g / n), b: Math.round(sum.b / n) }; +} + +export async function addWatermark(filename, text = 'Vendoo') { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_wm$1'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const fontSize = Math.max(16, Math.round(width * 0.04)); + const svg = ` + + ${text} + `; + + await image + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .toFile(outPath); + + return newName; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/mailer.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/mailer.mjs new file mode 100644 index 0000000..655893f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/mailer.mjs @@ -0,0 +1,214 @@ +import nodemailer from 'nodemailer'; + +let transporter = null; + +export function initMailer() { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587'); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + const from = process.env.SMTP_FROM || user; + + if (!host || !user || !pass) { + console.warn('SMTP nicht konfiguriert — Magic Links werden in der Konsole ausgegeben'); + return false; + } + + transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }); + + return true; +} + +function brandedHtml(title, content, buttonText, buttonUrl) { + return ` + + + + + +
                                              + + + + +
                                              +

                                              Vendoo

                                              +
                                              +

                                              ${title}

                                              +
                                              ${content}
                                              + ${buttonUrl ? ` + +

                                              Oder kopiere diesen Link:
                                              + ${buttonUrl}

                                              ` : ''} +
                                              +

                                              Vendoo — Marketplace Listing Generator

                                              +
                                              +
                                              +`; +} + +export async function sendMagicLink(email, token, baseUrl, type = 'login') { + const url = `${baseUrl}/auth/magic?token=${token}`; + + const isInvite = type === 'invite'; + const title = isInvite ? 'Du wurdest eingeladen!' : 'Dein Login-Link'; + const content = isInvite + ? `

                                              Du wurdest zum Vendoo eingeladen. Klicke auf den Button, um dein Konto zu aktivieren und loszulegen.

                                              Der Link ist 48 Stunden gültig.

                                              ` + : `

                                              Klicke auf den Button, um dich einzuloggen. Kein Passwort nötig.

                                              Der Link ist 15 Minuten gültig und kann nur einmal verwendet werden.

                                              `; + const buttonText = isInvite ? 'Einladung annehmen' : 'Jetzt einloggen'; + + const html = brandedHtml(title, content, buttonText, url); + const subject = isInvite ? 'Vendoo — Du wurdest eingeladen' : 'Vendoo — Dein Login-Link'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`MAGIC LINK (${type}) für ${email}:`); + console.log(url); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true, url }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Email senden fehlgeschlagen:', err.message); + console.log(`Fallback — Magic Link für ${email}: ${url}`); + return { sent: false, error: err.message, url }; + } +} + +export async function sendCredentials(email, password, name, baseUrl) { + const title = 'Dein Account wurde erstellt'; + const content = `

                                              Hallo${name ? ' ' + name : ''},

                                              +

                                              Ein Admin hat ein Konto für dich erstellt. Hier sind deine Zugangsdaten:

                                              + + + +
                                              E-Mail${email}
                                              Passwort${password}
                                              +

                                              Bitte ändere dein Passwort nach dem ersten Login.

                                              `; + const buttonText = 'Jetzt einloggen'; + const buttonUrl = baseUrl + '/login.html'; + + const html = brandedHtml(title, content, buttonText, buttonUrl); + const subject2 = 'Vendoo — Dein Account wurde erstellt'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`CREDENTIALS für ${email}:`); + console.log(`Passwort: ${password}`); + console.log(`Login: ${baseUrl}/login.html`); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: subject2, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Credentials email failed:', err.message); + console.log(`Fallback — Credentials für ${email}: Passwort=${password}`); + return { sent: false, error: err.message }; + } +} + +export async function sendNotificationEmail(email, subject, title, content) { + const html = brandedHtml(title, content, null, null); + + if (!transporter) { + console.log(`Notification for ${email}: ${subject}`); + return { sent: false }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: `Vendoo — ${subject}`, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Notification email failed:', err.message); + return { sent: false, error: err.message }; + } +} + +export function isMailerConfigured() { + return transporter !== null; +} + +export function reinitMailer(config) { + if (!config || !config.host || !config.user || !config.pass) { + transporter = null; + return false; + } + const port = parseInt(config.port || '587'); + transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: { user: config.user, pass: config.pass }, + tls: { rejectUnauthorized: false }, + }); + return true; +} + +export function getMailerConfig() { + if (!transporter || !transporter.options) { + return { + host: process.env.SMTP_HOST || '', + port: process.env.SMTP_PORT || '587', + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS ? '••••••••' : '', + from: process.env.SMTP_FROM || process.env.SMTP_USER || '', + configured: transporter !== null, + }; + } + const opts = transporter.options; + return { + host: opts.host || '', + port: String(opts.port || '587'), + user: opts.auth?.user || '', + pass: opts.auth?.pass ? '••••••••' : '', + from: process.env.SMTP_FROM || opts.auth?.user || '', + configured: true, + }; +} + +export async function testMailer(toEmail) { + if (!transporter) throw new Error('SMTP nicht konfiguriert'); + const html = ` + + +

                                              Vendoo — SMTP Test

                                              +

                                              Diese E-Mail bestätigt, dass deine SMTP-Konfiguration korrekt funktioniert.

                                              +

                                              Gesendet: ${new Date().toLocaleString('de-DE')}

                                              +`; + + await transporter.sendMail({ + from: process.env.SMTP_FROM || transporter.options?.auth?.user, + to: toEmail, + subject: 'Vendoo — SMTP Testmail', + html, + }); + return { sent: true }; +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/zip.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/zip.mjs new file mode 100644 index 0000000..e4e84b6 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/lib/zip.mjs @@ -0,0 +1,89 @@ +import { deflateRawSync } from 'zlib'; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = date.getDate(); + const month = date.getMonth() + 1; + const dosDate = ((year - 1980) << 9) | (month << 5) | day; + return { time, date: dosDate }; +} + +export function createZip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + const stamp = dosDateTime(); + + for (const file of files) { + const name = Buffer.from(String(file.name || 'file.bin').replace(/\\/g, '/'), 'utf8'); + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data || ''); + const compressed = deflateRawSync(data, { level: 6 }); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(stamp.time, 10); + local.writeUInt16LE(stamp.date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(stamp.time, 12); + central.writeUInt16LE(stamp.date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + + offset += local.length + name.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDirectory, end]); +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package-lock.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package-lock.json new file mode 100644 index 0000000..1d90f1e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package-lock.json @@ -0,0 +1,2220 @@ +{ + "name": "vendoo", + "version": "1.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vendoo", + "version": "1.6.2", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^11.7.0", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package.json b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package.json new file mode 100644 index 0000000..2ac310b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/package.json @@ -0,0 +1,23 @@ +{ + "name": "vendoo", + "version": "1.12.0", + "type": "module", + "private": true, + "scripts": { + "start": "node server.mjs", + "dev": "node --watch server.mjs", + "check": "node --check server.mjs && node --check lib/db.mjs && node --check public/app.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^11.7.0", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-de.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-de.mjs new file mode 100644 index 0000000..7895862 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-de.mjs @@ -0,0 +1,45 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'eBay.de', + id: 'ebay-de', + fields: ['title', 'description', 'tags'], + maxTitleLength: 80, + maxTags: 0, + feePercent: 11, + publishUrl: 'https://www.ebay.de/sell/create', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay.de-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 80 Zeichen). Marke, Artikelart, Größe, Farbe, Zustand. Nutze gängige eBay-Suchbegriffe. +- **description**: Professionelle Artikelbeschreibung (5-8 Sätze). Material, Zustand detailliert, Maße, Besonderheiten, Versandhinweise. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Smartphone") +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Neu mit Etikett / Wie neu / Gut / Akzeptabel / Für Bastler) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und aktuelle eBay-Marktpreise + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-ka.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-ka.mjs new file mode 100644 index 0000000..e9da28e --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/ebay-ka.mjs @@ -0,0 +1,39 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', +}; + +export default { + name: 'eBay Kleinanzeigen', + id: 'ebay-ka', + fields: ['title', 'description', 'tags'], + maxTitleLength: 65, + maxTags: 0, + feePercent: 0, + shippingNote: 'eBay KA erhebt keine Verkaufsgebühren. Versandkosten variieren.', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay-Kleinanzeigen-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten einer Anzeige' : 'eine Anzeige'}. + +Für jede Variante liefere: +- **title**: Prägnanter Titel (max. 65 Zeichen). Marke, Artikelart, Größe/Maße, Zustand. +- **description**: Ausführliche Beschreibung (5-8 Sätze). Zustand detailliert, Material, Maße, Abholung/Versand. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Wie neu / Gut / Akzeptabel / Defekt) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/etsy.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/etsy.mjs new file mode 100644 index 0000000..9b317ec --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/etsy.mjs @@ -0,0 +1,34 @@ +export default { + name: 'Etsy', + id: 'etsy', + fields: ['title', 'description', 'tags'], + maxTitleLength: 140, + maxTags: 13, + feePercent: 6.5, + listingFee: 0.20, + + buildPrompt(sellerNotes, { language = 'en', variants = 1 } = {}) { + const langNote = language === 'en' ? 'Write in English.' : language === 'de' ? 'Schreibe auf Deutsch.' : 'Write in English.'; + return `Du bist ein erfahrener Etsy-Verkäufer. ${langNote} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 140 Zeichen). Keywords, Marke, Material, Stil. +- **description**: Ansprechende Beschreibung (4-6 Sätze). Material, Besonderheiten, Maße, Zustand. Freundlicher Ton. +- **tags**: 13 relevante Tags (kurze Phrasen, OHNE #). + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unknown"/"Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (New / Like new / Good / Fair) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (einbauen, ggf. übersetzen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":["..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":["..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/vinted.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/vinted.mjs new file mode 100644 index 0000000..d0cbd2f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/platforms/vinted.mjs @@ -0,0 +1,44 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'Vinted', + id: 'vinted', + fields: ['title', 'description', 'hashtags'], + maxTitleLength: 100, + maxTags: 5, + feePercent: 5, + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener Vinted-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 100 Zeichen). Nenne Marke, Größe, Farbe, Artikelart. +- **description**: Verkaufsfördernde Beschreibung (3-5 Sätze). Material, Zustand, Besonderheiten. +- **hashtags**: Genau 5 relevante Hashtags mit # (z.B. #vintage #nike #sommer) + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Kleid") +- **size**: Größe falls erkennbar (z.B. "M", "42", "38") +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu mit Etikett / Neu ohne Etikett / Sehr gut / Gut / Befriedigend) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und Typ + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","hashtags":["#..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","hashtags":["#..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-192x192.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-192x192.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-512x512.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/android-chrome-512x512.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/app.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/app.js new file mode 100644 index 0000000..9a01d32 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/app.js @@ -0,0 +1,5554 @@ +const state = { + photos: [], + platform: '', + aiProvider: 'claude', + aiModel: '', + language: 'de', + variants: 1, + currentListing: null, + allVariants: null, + platforms: [], + providers: [], + templates: [], + editingTemplateId: null, + historyPage: 1, + historyPageSize: 10, + publishPage: 1, + publishPageSize: 10, + aiModelPhotos: [], + aiModelGenerations: [], +}; + +const editors = {}; + +const htmlSyncState = { generatorLock: false, detailLock: false }; +let detailEditorState = null; +const mobileUploadState = { session: null, pollTimer: null, target: null, imported: new Set() }; + + +const QUILL_TOOLBAR = [ + [{ header: [1, 2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ color: [] }, { background: [] }], + [{ list: 'ordered' }, { list: 'bullet' }], + [{ align: [] }], + ['blockquote', 'link'], + ['clean'], +]; + +function initQuill(container, placeholder) { + if (typeof Quill === 'undefined') return null; + return new Quill(container, { + theme: 'snow', + placeholder: placeholder || '', + modules: { toolbar: QUILL_TOOLBAR }, + }); +} + +const RICH_ALLOWED_TAGS = new Set([ + 'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'OL', 'UL', 'LI', + 'BLOCKQUOTE', 'A', 'H1', 'H2', 'H3', 'SPAN', 'DIV', 'SUB', 'SUP', 'CODE', 'PRE', +]); +const RICH_DANGEROUS_TAGS = new Set([ + 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', + 'SELECT', 'OPTION', 'SVG', 'MATH', 'VIDEO', 'AUDIO', 'LINK', 'META', +]); + +function sanitizeStyleClient(style) { + return String(style || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf(':'); + if (index < 1) return ''; + const name = part.slice(0, index).trim().toLowerCase(); + const value = part.slice(index + 1).trim(); + if (!['color', 'background-color', 'text-align'].includes(name)) return ''; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return ''; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) return ''; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) return ''; + return `${name}:${value}`; + }).filter(Boolean).join(';'); +} + +function sanitizeRichHtmlClient(input) { + const doc = new DOMParser().parseFromString(`${String(input || '')}`, 'text/html'); + const elements = [...doc.body.querySelectorAll('*')].reverse(); + for (const element of elements) { + if (RICH_DANGEROUS_TAGS.has(element.tagName)) { + element.remove(); + continue; + } + if (!RICH_ALLOWED_TAGS.has(element.tagName)) { + element.replaceWith(...element.childNodes); + continue; + } + for (const attr of [...element.attributes]) { + const name = attr.name.toLowerCase(); + const value = attr.value; + let keep = false; + if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:|tel:|#|\/)/i.test(value.trim())) keep = true; + if (element.tagName === 'A' && name === 'target' && ['_blank', '_self'].includes(value)) keep = true; + if (element.tagName === 'A' && name === 'rel') { element.setAttribute('rel', 'noopener noreferrer'); keep = true; } + if (name === 'style') { + const safeStyle = sanitizeStyleClient(value); + if (safeStyle) { element.setAttribute('style', safeStyle); keep = true; } + } + if (name === 'class' && String(value).split(/\s+/).every(cls => /^ql-[a-z0-9-]+$/i.test(cls))) keep = true; + if (!keep) element.removeAttribute(attr.name); + } + if (element.tagName === 'A' && element.getAttribute('target') === '_blank') element.setAttribute('rel', 'noopener noreferrer'); + } + return doc.body.innerHTML.trim(); +} + +function plainTextToRichHtml(text) { + const value = String(text || '').trim(); + if (!value) return ''; + return value.split(/\n{2,}/).map(paragraph => `

                                              ${escapeHtml(paragraph).replace(/\n/g, '
                                              ')}

                                              `).join(''); +} + +function richHtmlToText(html) { + const container = document.createElement('div'); + container.innerHTML = sanitizeRichHtmlClient(html); + container.querySelectorAll('br').forEach(br => br.replaceWith('\n')); + container.querySelectorAll('p,div,h1,h2,h3,li,blockquote,pre').forEach(node => node.append('\n')); + return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); +} + +function getEditorHtml(editor) { + if (!editor) return ''; + const html = sanitizeRichHtmlClient(editor.root.innerHTML); + return /^(


                                              <\/p>|

                                              <\/p>)$/i.test(html) ? '' : html; +} + +function setEditorHtml(editor, html) { + if (!editor) return; + const safe = sanitizeRichHtmlClient(html); + editor.setText(''); + if (safe) editor.clipboard.dangerouslyPasteHTML(safe); +} + +function setSandboxPreview(frame, html) { + if (!frame) return; + frame.srcdoc = `${String(html || '')}`; +} + +function setHtmlSyncStatus(id, text, tone = '') { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.dataset.tone = tone; +} + +async function init() { + // Auth check — redirect to login if not authenticated or setup needed + try { + const meCheck = await fetch('/api/me', { credentials: 'same-origin' }); + if (meCheck.status === 401) { window.location.href = '/login.html'; return; } + const meData = await meCheck.json(); + if (meData.setupMode) { window.location.href = '/login.html'; return; } + } catch {} + + const [platforms, settings, providers, templates] = await Promise.all([ + api('GET', '/api/platforms'), + api('GET', '/api/settings'), + api('GET', '/api/providers'), + api('GET', '/api/templates'), + ]); + + state.platforms = platforms; + state.providers = providers; + state.templates = templates; + state.platform = settings.default_platform || 'vinted'; + state.aiProvider = settings.default_ai || 'claude'; + state.language = settings.default_language || 'de'; + + renderPlatformButtons(); + renderProviderButtons(); + populateSettingsForm(settings, platforms, providers); + populateTemplateSelects(platforms); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('language-select').value = state.language; + + editors.result = initQuill('#result-description-editor', 'Beschreibung wird hier angezeigt...'); + editors.tplNotes = initQuill('#tpl-notes-editor', 'Verkäufernotizen...'); + + setupGeneratorHtmlSync(); + setupHtmlViewSwitches(); + setupMobileUpload(); + + addQuillTooltips(); + + setupTabs(); + setupAnalyticsDashboard(); + setupTodayFlow(); + setupUpload(); + setupImageTools(); + setupGenerate(); + setupGeneratorStudio(); + setupCopy(); + setupHistory(); + setupPublish(); + setupTrash(); + setupTemplates(); + setupFeeCalculator(); + setupSettings(); + setupDarkMode(); + setupPhotoEditor(); + setupBackup(); + setupNotifications(); + setupUserMenu(); + setupAdmin(); + loadAnalyticsDashboard(); + updateTrashBadge(); +} + +// --- API --- +let _csrfToken = ''; +function getCsrf() { + if (_csrfToken) return _csrfToken; + const m = document.cookie.match(/csrf_token=([^;]+)/); + return m ? m[1] : ''; +} +async function api(method, url, body) { + const opts = { method, headers: {}, credentials: 'same-origin' }; + if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD'].includes(method)) opts.headers['X-CSRF-Token'] = getCsrf(); + const res = await fetch(url, opts); + if (res.status === 401) { window.location.href = '/login.html'; return; } + if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || 'Fehler'); } + return res.json(); +} + + +function aiModelStatusClass(tone = 'neutral') { + return `ai-model-status ${tone ? `is-${tone}` : ''}`.trim(); +} + +function setAiModelStatus(elementId, message, tone = 'neutral') { + const element = document.getElementById(elementId); + if (!element) return; + element.textContent = message; + element.className = aiModelStatusClass(tone); +} + +function getGeneratorAiModelPayload() { + return { + photos: [...state.photos], + platform: state.platform, + title: document.getElementById('result-title')?.value || state.currentListing?.title || '', + category: metaCategoryPicker?.getValue?.() || state.currentListing?.category || '', + brand: document.getElementById('meta-brand')?.value || state.currentListing?.brand || '', + color: document.getElementById('meta-color')?.value || state.currentListing?.color || '', + size: document.getElementById('meta-size')?.value || state.currentListing?.size || '', + condition: document.getElementById('meta-condition')?.value || state.currentListing?.condition || '', + seller_notes: document.getElementById('generator-seller-notes')?.value || state.currentListing?.seller_notes || '', + mode: document.getElementById('generator-ai-model-mode')?.value || 'model', + preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('generator-ai-model-variants')?.value || '1', 10), + }; +} + +function buildAiModelCardHtml(items, scope) { + const prefix = scope === 'detail' ? 'detail' : 'generator'; + return ` +

                                              + ${items.length} sichere AI-Variante${items.length === 1 ? '' : 'n'} + +
                                              +
                                              + ${items.map((photo, index) => ` +
                                              + AI-Model-Foto ${index + 1} +
                                              + Variante ${photo.variant_index || index + 1} + ${escapeHtml((photo.mode || 'model').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())} +
                                              + +
                                              `).join('')} +
                                              `; +} + +function renderAiModelGrid(scope, items = []) { + const grid = document.getElementById(scope === 'detail' ? 'detail-ai-model-grid' : 'generator-ai-model-grid'); + if (!grid) return; + if (!items.length) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + return; + } + grid.innerHTML = buildAiModelCardHtml(items, scope); + grid.classList.remove('hidden'); + + const addOne = filename => { + if (scope === 'detail') { + if (!detailEditorState) return; + if (!detailEditorState.photos.includes(filename)) detailEditorState.photos.push(filename); + detailEditorState.activePhotoIndex = detailEditorState.photos.length - 1; + renderDetailPhotos(); + } else { + if (!state.photos.includes(filename)) state.photos.push(filename); + renderPhotoPreviews(); + updateGenerateButton(); + } + toast('AI-Model-Foto hinzugefügt'); + }; + + grid.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => addOne(button.dataset.aiModelAddOne))); + grid.querySelector('[data-ai-model-add-all]')?.addEventListener('click', () => { + items.forEach(photo => addOne(photo.image_path || photo.filename)); + toast('Alle AI-Model-Fotos hinzugefügt'); + }); +} + +async function pollAiModelJob(jobId, onProgress) { + const started = Date.now(); + while (Date.now() - started < 240000) { + const job = await api('GET', `/api/ai-model-photos/jobs/${jobId}`); + if (typeof onProgress === 'function') onProgress(job); + if (job.status === 'completed' || job.status === 'failed') return job; + await new Promise(resolve => setTimeout(resolve, 1800)); + } + throw new Error('Zeitüberschreitung bei der AI-Bildgenerierung'); +} + +function formatAiJobStatus(job) { + if (!job) return 'AI-Bildjob wird vorbereitet …'; + if (job.status === 'queued') return 'AI-Bildjob steht in der Warteschlange …'; + if (job.status === 'running') return `AI-Bilder werden erzeugt … ${job.progress || 10}%`; + if (job.status === 'failed') return job.error_message || 'AI-Bildjob fehlgeschlagen'; + return 'AI-Bildjob abgeschlossen'; +} + +async function runGeneratorAiModelPhotos() { + if (!state.photos.length) return toast('Bitte zuerst Produktfotos hochladen'); + const button = document.getElementById('generator-ai-model-btn'); + button.disabled = true; + setAiModelStatus('generator-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const payload = getGeneratorAiModelPayload(); + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('generator-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelStatus('generator-ai-model-status', formatAiJobStatus(job), job.status === 'failed' ? 'danger' : 'working'); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + state.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('generator', state.aiModelPhotos); + const providerLabel = state.aiModelPhotos[0]?.provider_model || state.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('generator-ai-model-status', `${state.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + } catch (err) { + setAiModelStatus('generator-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + button.disabled = false; + } +} + +async function runDetailAiModelPhotos() { + if (!detailEditorState?.photos?.length) return toast('Bitte zuerst Produktfotos hinzufügen'); + const button = document.getElementById('detail-ai-model-btn'); + if (button) button.disabled = true; + setAiModelStatus('detail-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const payload = { + listing_id: detailEditorState.listing.id, + photos: [...detailEditorState.photos], + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || detailEditorState.listing.title || '', + category: detailEditorState.categoryPicker?.getValue?.() || detailEditorState.listing.category || '', + brand: document.getElementById('detail-brand')?.value || detailEditorState.listing.brand || '', + color: document.getElementById('detail-color')?.value || detailEditorState.listing.color || '', + size: document.getElementById('detail-size')?.value || detailEditorState.listing.size || '', + condition: document.getElementById('detail-condition')?.value || detailEditorState.listing.condition || '', + seller_notes: document.getElementById('detail-seller-notes')?.value || detailEditorState.listing.seller_notes || '', + mode: document.getElementById('detail-ai-model-mode')?.value || 'model', + preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('detail-ai-model-variants')?.value || '1', 10), + }; + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('detail-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelStatus('detail-ai-model-status', formatAiJobStatus(job), job.status === 'failed' ? 'danger' : 'working'); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + detailEditorState.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + const providerLabel = detailEditorState.aiModelPhotos[0]?.provider_model || detailEditorState.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + loadDetailAiModelGenerations(detailEditorState.listing.id); + } catch (err) { + setAiModelStatus('detail-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + if (button) button.disabled = false; + } +} + +async function loadDetailAiModelPhotos(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-photos`); + detailEditorState.aiModelPhotos = items || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + if (detailEditorState.aiModelPhotos.length) { + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} vorhandene AI-Bilder geladen.`, 'neutral'); + } + } catch {} +} + +function renderDetailAiModelGenerations(generations = []) { + const wrap = document.getElementById('detail-ai-model-history'); + if (!wrap) return; + if (!generations.length) { + wrap.innerHTML = '
                                              Noch keine AI-Bildhistorie vorhanden.
                                              '; + return; + } + wrap.innerHTML = generations.slice(0, 6).map(gen => ` +
                                              +
                                              ${escapeHtml((gen.mode || 'model').replace('_', ' '))}${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder
                                              +
                                              + ${(gen.photos || []).slice(0, 3).map(photo => ``).join('')} +
                                              +
                                              + `).join(''); + wrap.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => { + const file = button.dataset.aiModelAddOne; + if (!detailEditorState.photos.includes(file)) detailEditorState.photos.push(file); + renderDetailPhotos(); + toast('AI-Bild aus Historie hinzugefügt'); + })); +} + +async function loadDetailAiModelGenerations(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-generations`); + detailEditorState.aiModelGenerations = items || []; + renderDetailAiModelGenerations(detailEditorState.aiModelGenerations); + } catch {} +} + + + +// --- Tab titles for topbar --- +const TAB_TITLES = { + today: 'Studio Flow', + dashboard: 'Dashboard', + generator: 'Neues Listing', + templates: 'Vorlagen', + history: 'Listings / Historie', + publish: 'Publish', + trash: 'Papierkorb', + inventory: 'Lager', + fees: 'Gebühren', + admin: 'Admin / Benutzer & System', + settings: 'Einstellungen', +}; + +const TAB_KICKERS = { + today: 'Arbeitsplatz', + dashboard: 'Analytics', + generator: 'Generator', + templates: 'Inhalte', + history: 'Übersicht', + publish: 'Control Center', + trash: 'Wiederherstellung', + inventory: 'Bestand', + fees: 'Kalkulation', + admin: 'Verwaltung', + settings: 'Konfiguration', +}; + +function setMobileSidebarOpen(open) { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const toggle = document.getElementById('sidebar-toggle'); + if (!sidebar || !overlay) return; + sidebar.classList.toggle('open', !!open); + overlay.classList.toggle('show', !!open); + document.body.classList.toggle('mobile-nav-open', !!open); + toggle?.setAttribute('aria-expanded', open ? 'true' : 'false'); + sidebar.setAttribute('aria-hidden', (!open && window.matchMedia('(max-width: 768px)').matches) ? 'true' : 'false'); + if (open) { + window.setTimeout(() => sidebar.querySelector('.nav-item.active, .nav-item')?.focus(), 60); + } else { + toggle?.focus({ preventScroll: true }); + } +} + +function closeMobileSidebar({ restoreFocus = false } = {}) { + const toggle = document.getElementById('sidebar-toggle'); + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + sidebar?.classList.remove('open'); + overlay?.classList.remove('show'); + document.body.classList.remove('mobile-nav-open'); + toggle?.setAttribute('aria-expanded', 'false'); + if (sidebar && window.matchMedia('(max-width: 768px)').matches) sidebar.setAttribute('aria-hidden', 'true'); + if (restoreFocus) toggle?.focus({ preventScroll: true }); +} + +function switchToTab(tabId, adminTab) { + document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + const section = document.getElementById(tabId); + if (section) section.classList.add('active'); + + // Highlight the correct sidebar item + const selector = adminTab + ? `.nav-item[data-tab="${tabId}"][data-admin-tab="${adminTab}"]` + : `.nav-item[data-tab="${tabId}"]:not([data-admin-tab])`; + const activeBtn = document.querySelector(selector) + || document.querySelector(`.nav-item[data-tab="${tabId}"]`); + if (activeBtn) activeBtn.classList.add('active'); + + // Update topbar title + const titleEl = document.getElementById('topbar-title'); + if (titleEl) titleEl.textContent = TAB_TITLES[tabId] || tabId; + const kickerEl = document.getElementById('topbar-kicker'); + if (kickerEl) kickerEl.textContent = TAB_KICKERS[tabId] || ''; + + // Switch admin sub-tab if specified + if (tabId === 'admin' && adminTab) { + switchAdminTab(adminTab); + if (adminTab === 'users') loadAdminUsers(); + if (adminTab === 'sessions') loadAdminSessions(); + if (adminTab === 'smtp') loadSmtpSettings(); + if (adminTab === 'system') loadSystemInfo(); + if (adminTab === 'audit') loadAdminAudit(); + } + + // Load data for the tab + if (tabId === 'today') loadToday(); + if (tabId === 'dashboard') loadAnalyticsDashboard(); + if (tabId === 'history') { setHistoryDetailMode(false); loadHistory(); } + if (tabId === 'publish') loadPublishList(); + if (tabId === 'trash') loadTrash(); + if (tabId === 'templates') renderTemplateList(); + if (tabId === 'inventory') loadInventory(); + if (tabId === 'fees') updateFeeCalculator(); + + // Close mobile sidebar + closeMobileSidebar(); +} + +// --- Tabs --- +function setupTabs() { + document.querySelectorAll('.nav-item').forEach(btn => { + btn.addEventListener('click', () => { + switchToTab(btn.dataset.tab, btn.dataset.adminTab); + }); + }); + + // Mobile sidebar toggle + document.getElementById('sidebar-toggle')?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const open = !document.getElementById('sidebar')?.classList.contains('open'); + setMobileSidebarOpen(open); + }); + document.getElementById('sidebar-close')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.getElementById('sidebar-overlay')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && document.getElementById('sidebar')?.classList.contains('open')) { + closeMobileSidebar({ restoreFocus: true }); + } + }); + window.addEventListener('resize', debounce(() => { + const mobile = window.matchMedia('(max-width: 768px)').matches; + const sidebar = document.getElementById('sidebar'); + if (!mobile) { + closeMobileSidebar(); + sidebar?.setAttribute('aria-hidden', 'false'); + } else if (!sidebar?.classList.contains('open')) { + sidebar?.setAttribute('aria-hidden', 'true'); + } + }, 120)); + if (window.matchMedia('(max-width: 768px)').matches) document.getElementById('sidebar')?.setAttribute('aria-hidden', 'true'); + + // Topbar "Neues Listing" button + document.getElementById('topbar-new-btn')?.addEventListener('click', () => { + switchToTab('generator'); + }); + + // Topbar search button — focus search in history + document.getElementById('topbar-search-btn')?.addEventListener('click', () => { + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + }); + + document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + } + }); + + document.addEventListener('click', event => { + const target = event.target.closest('[data-tab-target]'); + if (target) switchToTab(target.dataset.tabTarget); + }); +} + +// --- Platforms --- +function renderPlatformButtons() { + const c = document.getElementById('platform-buttons'); + c.innerHTML = ''; + for (const p of state.platforms) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.platform ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.platform = p.id; + updateGeneratorReadiness(); + refreshGeneratorPlatformPreview(); + if (!document.getElementById('meta-section').classList.contains('hidden') && metaCategoryPicker) { + const currentVal = metaCategoryPicker.getValue(); + metaCategoryPicker = createCategoryPicker('meta-category-picker', p.id, null); + if (currentVal) { + const name = currentVal.split(' > ').pop(); + api('GET', `/api/categories/${p.id}/match?q=${encodeURIComponent(name)}`).then(matches => { + if (matches.length && matches[0].score >= 50) metaCategoryPicker.setValue(matches[0].pathStr); + }).catch(() => {}); + } + } + }); + c.appendChild(btn); + } +} + +// --- Providers --- +function renderProviderButtons() { + const c = document.getElementById('provider-buttons'); + c.innerHTML = ''; + for (const p of state.providers) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.aiProvider ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.aiProvider = p.id; + state.aiModel = ''; + updateModelDropdown(); + }); + c.appendChild(btn); + } + updateModelDropdown(); +} + +function updateModelDropdown() { + const wrap = document.getElementById('model-select-wrap'); + const sel = document.getElementById('model-select'); + const prov = state.providers.find(p => p.id === state.aiProvider); + if (prov?.hasModels && prov.models?.length) { + const isOpenRouter = prov.id === 'openrouter'; + const options = isOpenRouter + ? `` + prov.models.map(m => ``).join('') + : prov.models.map(m => ``).join(''); + sel.innerHTML = options; + state.aiModel = isOpenRouter ? '' : prov.models[0].id; + sel.onchange = () => { state.aiModel = sel.value; }; + wrap.classList.remove('hidden'); + } else { + state.aiModel = ''; + wrap.classList.add('hidden'); + } +} + +// --- Upload --- +function setupUpload() { + const dz = document.getElementById('dropzone'); + const fi = document.getElementById('file-input'); + dz.addEventListener('click', () => fi.click()); + dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); }); + dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); + dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); + fi.addEventListener('change', () => { handleFiles(fi.files); fi.value = ''; }); + enablePhotoDragSort(); +} + +async function handleFiles(files) { + const available = Math.max(0, 10 - state.photos.length); + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/')); + if (!available) { toast('Maximal 10 Fotos pro Listing'); return; } + const accepted = imageFiles.slice(0, available); + if (imageFiles.length > available) toast(`Es wurden nur ${available} weitere Fotos übernommen`); + const fd = new FormData(); + fd.append('platform', state.platform || 'allgemein'); + for (const file of accepted) fd.append('photos', file); + if (!fd.has('photos')) return; + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(state.platform || 'allgemein')}`, { + method: 'POST', + body: fd, + headers: { 'X-CSRF-Token': getCsrf() }, + credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status})`); + state.photos.push(...(data.filenames || [])); + renderPhotoPreviews(); + updateGenerateButton(); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function renderPhotoPreviews() { + renderPhotoPreview(); +} + +function renderPhotoPreview() { + const c = document.getElementById('photo-preview'); + c.innerHTML = ''; + for (let i = 0; i < state.photos.length; i++) { + const div = document.createElement('div'); + div.className = 'thumb'; + div.draggable = true; + div.dataset.index = String(i + 1); + const safeUrl = photoUrl(state.photos[i]); + div.innerHTML = `Foto ${i + 1}`; + div.querySelector('img').addEventListener('click', e => { + e.stopPropagation(); + if (i > 0) { + const selected = state.photos.splice(i, 1)[0]; + state.photos.unshift(selected); + renderPhotoPreview(); + toast('Coverfoto geändert'); + } + }); + div.querySelector('.remove-photo').addEventListener('click', e => { + e.stopPropagation(); + state.photos.splice(i, 1); + renderPhotoPreview(); + updateGenerateButton(); + }); + div.querySelector('.edit-photo').addEventListener('click', e => { + e.stopPropagation(); + openPhotoEditor(i); + }); + c.appendChild(div); + } + document.getElementById('image-tools').classList.toggle('hidden', state.photos.length === 0); + updateGeneratorPhotoStage(); +} + +function updateGenerateButton() { + const ready = state.photos.length > 0; + document.getElementById('generate-btn').disabled = !ready; + if (!ready) { + state.aiModelPhotos = []; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + } else if (!state.aiModelPhotos.length) { + setAiModelStatus('generator-ai-model-status', 'Bereit für sichere AI-Model-Fotos.', 'neutral'); + } + updateGeneratorReadiness(); +} + +// --- Image tools --- +function setupImageTools() { + document.getElementById('remove-bg-btn').addEventListener('click', async () => { + if (!state.photos.length) return; + const btn = document.getElementById('remove-bg-btn'); + btn.disabled = true; btn.textContent = 'Verarbeite...'; + try { + const newPhotos = []; + for (const photo of state.photos) { + const res = await api('POST', '/api/images/remove-bg', { filename: photo }); + newPhotos.push(res.filename); + } + state.photos = newPhotos; + renderPhotoPreviews(); + toast('Hintergrund entfernt!'); + } catch (err) { toast('Fehler: ' + err.message); } + finally { btn.disabled = false; btn.textContent = 'Hintergrund entfernen'; } + }); + + document.getElementById('watermark-btn').addEventListener('click', async () => { + if (!state.photos.length) return; + const btn = document.getElementById('watermark-btn'); + btn.disabled = true; + try { + const newPhotos = []; + for (const photo of state.photos) { + const res = await api('POST', '/api/images/watermark', { filename: photo, text: 'Vendoo' }); + newPhotos.push(res.filename); + } + state.photos = newPhotos; + renderPhotoPreviews(); + toast('Wasserzeichen hinzugefügt!'); + } catch (err) { toast('Fehler: ' + err.message); } + finally { btn.disabled = false; } + }); +} + +// --- Generate --- +function setupGenerate() { + document.getElementById('generate-btn').addEventListener('click', generateListing); + document.getElementById('reset-generator-btn').addEventListener('click', resetGenerator); + document.getElementById('language-select').addEventListener('change', e => { state.language = e.target.value; updateGeneratorReadiness(); }); + document.getElementById('variants-select').addEventListener('change', e => { state.variants = parseInt(e.target.value); }); + document.getElementById('template-select').addEventListener('change', e => applyGeneratorTemplate(e.target.value)); + document.getElementById('generator-ai-model-btn')?.addEventListener('click', runGeneratorAiModelPhotos); +} + +let generatorRenderSequence = 0; + +function getGeneratorListingForHtml() { + const tags = (document.getElementById('result-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: state.platform, + title: document.getElementById('result-title')?.value || '', + description: editors.result ? editors.result.getText().trim() : '', + description_html: getEditorHtml(editors.result), + tags, + brand: document.getElementById('meta-brand')?.value || null, + category: metaCategoryPicker?.getValue?.() || null, + size: document.getElementById('meta-size')?.value || null, + color: document.getElementById('meta-color')?.value || null, + condition: document.getElementById('meta-condition')?.value || null, + }; +} + +const refreshGeneratorPlatformPreview = debounce(async () => { + if (!state.currentListing || document.getElementById('result-section')?.classList.contains('hidden')) return; + const sequence = ++generatorRenderSequence; + setHtmlSyncStatus('generator-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const listing = getGeneratorListingForHtml(); + const data = await api('POST', '/api/html-render', { platform: state.platform, listing }); + if (sequence !== generatorRenderSequence) return; + state.currentHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + } catch (err) { + if (sequence !== generatorRenderSequence) return; + setSandboxPreview(document.getElementById('html-preview'), getEditorHtml(editors.result)); + setHtmlSyncStatus('generator-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupGeneratorHtmlSync() { + const editor = editors.result; + const source = document.getElementById('html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.generatorLock) return; + source.value = getEditorHtml(editor); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }); + + const applySourceToEditor = debounce(() => { + htmlSyncState.generatorLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.generatorLock = false; + source.value = safe; + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('generator-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('html-preview'), sanitizeRichHtmlClient(source.value)); + applySourceToEditor(); + }); +} + + +function setHtmlView(scope, view) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + const active = button.dataset.htmlView === view; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + const root = scope === 'generator' ? document.getElementById('html-preview-wrap') : document.querySelector('.detail-platform-workbench'); + root?.querySelectorAll('.html-switch-view').forEach(panel => panel.classList.toggle('is-active', panel.dataset.htmlPanel === view)); +} + +function bindHtmlViewTabs(scope) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + if (button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', () => setHtmlView(scope, button.dataset.htmlView)); + }); +} + +function setupHtmlViewSwitches() { + bindHtmlViewTabs('generator'); + setHtmlView('generator', 'preview'); +} + +function setupMobileUpload() { + document.getElementById('generator-mobile-upload')?.addEventListener('click', () => openMobileUpload('generator')); + document.getElementById('mobile-upload-close')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-done')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-new-session')?.addEventListener('click', () => openMobileUpload(mobileUploadState.target?.context || 'generator', mobileUploadState.target?.listingId, true)); + document.getElementById('mobile-upload-copy-link')?.addEventListener('click', async () => { + const input = document.getElementById('mobile-upload-link'); + if (!input?.value) return; + await navigator.clipboard.writeText(input.value); + toast('Upload-Link kopiert'); + }); + document.getElementById('mobile-upload-overlay')?.addEventListener('click', event => { + if (event.target.id === 'mobile-upload-overlay') closeMobileUpload(); + }); +} + +async function openMobileUpload(context = 'generator', listingId = null, renew = false) { + if (renew && mobileUploadState.session?.id) { + try { await api('DELETE', `/api/mobile-upload-sessions/${mobileUploadState.session.id}`); } catch {} + } + stopMobileUploadPolling(); + mobileUploadState.target = { context, listingId: listingId || null }; + mobileUploadState.imported = new Set(context === 'listing' ? (detailEditorState?.photos || []) : state.photos); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.remove('hidden'); + overlay?.setAttribute('aria-hidden', 'false'); + setMobileUploadDialogState('Link wird vorbereitet …', 'working'); + try { + const session = await api('POST', '/api/mobile-upload-sessions', { context, listing_id: listingId || null }); + mobileUploadState.session = session; + const link = document.getElementById('mobile-upload-link'); + if (link) link.value = session.url; + const qrCanvas = document.getElementById('mobile-upload-qr'); + if (typeof QRious !== 'undefined' && qrCanvas) new QRious({ element: qrCanvas, value: session.url, size: 240, level: 'M', foreground: '#171714', background: '#ffffff' }); + const expiry = new Date(session.expires_at); + document.getElementById('mobile-upload-expiry').textContent = `Gültig bis ${expiry.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + const localOnly = /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(session.url); + const networkNote = document.getElementById('mobile-upload-network-note'); + if (networkNote) { + networkNote.textContent = localOnly + ? 'Keine LAN-IP erkannt. Prüfe Windows-Firewall und Netzwerkadapter oder setze PUBLIC_BASE_URL manuell.' + : `Handy und Vendoo-PC müssen im selben Netzwerk sein. Erkannte Adresse: ${session.base_url || session.url.split('/mobile-upload/')[0]}`; + } + setMobileUploadDialogState(localOnly ? 'LAN-Adresse prüfen' : 'Bereit zum Scannen', localOnly ? 'warning' : 'ready'); + updateMobileUploadCount([]); + mobileUploadState.pollTimer = window.setInterval(pollMobileUploadSession, 1500); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + } +} + +function setMobileUploadDialogState(text, tone) { + const element = document.getElementById('mobile-upload-session-state'); + if (!element) return; + element.lastChild.textContent = text; + element.dataset.tone = tone; +} + +function updateMobileUploadCount(files) { + const count = document.getElementById('mobile-upload-file-count'); + if (count) count.textContent = `${files.length} ${files.length === 1 ? 'Bild' : 'Bilder'} empfangen`; +} + +async function pollMobileUploadSession() { + const session = mobileUploadState.session; + if (!session?.id) return; + try { + const data = await api('GET', `/api/mobile-upload-sessions/${session.id}`); + const files = Array.isArray(data.files) ? data.files : []; + const fresh = files.filter(file => !mobileUploadState.imported.has(file)); + fresh.forEach(file => mobileUploadState.imported.add(file)); + if (fresh.length) { + if (mobileUploadState.target?.context === 'listing' && detailEditorState) { + detailEditorState.photos = [...new Set([...detailEditorState.photos, ...fresh])].slice(0, 10); + renderDetailPhotos(); + } else { + state.photos = [...new Set([...state.photos, ...fresh])].slice(0, 10); + renderPhotoPreviews(); + updateGeneratorReadiness(); + } + toast(`${fresh.length} ${fresh.length === 1 ? 'Bild empfangen' : 'Bilder empfangen'}`); + } + updateMobileUploadCount(files); + if (files.length) setMobileUploadDialogState('Bilder werden live übertragen', 'ready'); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + stopMobileUploadPolling(); + } +} + +function stopMobileUploadPolling() { + if (mobileUploadState.pollTimer) window.clearInterval(mobileUploadState.pollTimer); + mobileUploadState.pollTimer = null; +} + +function closeMobileUpload() { + stopMobileUploadPolling(); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.add('hidden'); + overlay?.setAttribute('aria-hidden', 'true'); +} + +function setupGeneratorStudio() { + const notes = document.getElementById('generator-seller-notes'); + notes?.addEventListener('input', () => { + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes.value.length} / 500`; + }); + document.getElementById('result-title')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('result-tags')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('meta-price')?.addEventListener('input', debounce(updateGeneratorFeePreview, 250)); + document.getElementById('generator-download-photos')?.addEventListener('click', () => downloadPublishPhotos(state.photos)); + ['meta-brand', 'meta-size', 'meta-color', 'meta-condition'].forEach(id => { + document.getElementById(id)?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + }); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +function applyGeneratorTemplate(value) { + const tpl = state.templates.find(t => t.id === parseInt(value)); + const notes = document.getElementById('generator-seller-notes'); + const tags = document.getElementById('generator-template-tags'); + if (!tpl) { + if (notes) notes.value = ''; + if (tags) { tags.innerHTML = ''; tags.classList.add('hidden'); } + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = '0 / 500'; + return; + } + if (tpl.platform) { + state.platform = tpl.platform; + renderPlatformButtons(); + } + if (tpl.language) { + state.language = tpl.language; + document.getElementById('language-select').value = tpl.language; + } + if (notes) notes.value = (tpl.seller_notes || '').slice(0, 500); + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes?.value.length || 0} / 500`; + const defaultTags = Array.isArray(tpl.default_tags) ? tpl.default_tags : []; + if (tags) { + tags.innerHTML = defaultTags.map(tag => `${escapeHtml(tag)}`).join(''); + tags.classList.toggle('hidden', defaultTags.length === 0); + } + updateGeneratorReadiness(); +} + +function updateGeneratorPhotoStage() { + const count = state.photos.length; + const cover = document.getElementById('generator-cover'); + const empty = document.getElementById('generator-photo-empty'); + const badge = document.getElementById('generator-cover-badge'); + const countEl = document.getElementById('generator-photo-count'); + if (countEl) countEl.textContent = `${count} / 10`; + if (cover && empty && badge) { + if (count) { + cover.src = photoUrl(state.photos[0]); + cover.classList.remove('hidden'); + empty.classList.add('hidden'); + badge.classList.remove('hidden'); + } else { + cover.removeAttribute('src'); + cover.classList.add('hidden'); + empty.classList.remove('hidden'); + badge.classList.add('hidden'); + } + } + const completed = { + count: count >= 3, + cover: count >= 1, + details: count >= 4, + background: count >= 2, + order: count >= 2, + }; + let score = 0; + document.querySelectorAll('#generator-photo-tips [data-photo-tip]').forEach(item => { + const done = Boolean(completed[item.dataset.photoTip]); + item.classList.toggle('is-complete', done); + if (done) score += 1; + }); + const scoreEl = document.getElementById('generator-photo-quality-score'); + if (scoreEl) scoreEl.textContent = `${score} / 5`; + document.getElementById('generator-step-photos')?.classList.toggle('is-complete', count > 0); +} + +function updateGeneratorReadiness() { + const wrapper = document.querySelector('.generator-readiness'); + const title = document.getElementById('generator-readiness-title'); + const copy = document.getElementById('generator-readiness-copy'); + const ready = state.photos.length > 0; + wrapper?.classList.toggle('is-ready', ready); + if (title) title.textContent = ready ? 'Bereit zur Analyse' : 'Fotos fehlen'; + if (copy) copy.textContent = ready + ? `${state.photos.length} Foto${state.photos.length === 1 ? '' : 's'} · ${platformLabel(state.platform)} · ${state.language.toUpperCase()}` + : 'Lade mindestens ein Produktfoto hoch.'; + document.getElementById('generator-step-ai')?.classList.toggle('is-active', ready && !state.currentListing); +} + +function updateGeneratorResultMetrics() { + const title = document.getElementById('result-title')?.value || ''; + const description = editors.result ? editors.result.getText().trim() : ''; + const tagValue = document.getElementById('result-tags')?.value || ''; + const tags = tagValue.split(',').map(tag => tag.trim()).filter(Boolean); + const titleCount = document.getElementById('result-title-count'); + const descriptionCount = document.getElementById('result-description-count'); + const tagsCount = document.getElementById('result-tags-count'); + if (titleCount) titleCount.textContent = `${title.length} / 80`; + if (descriptionCount) descriptionCount.textContent = `${description.length} Zeichen`; + if (tagsCount) tagsCount.textContent = `${tags.length} Tag${tags.length === 1 ? '' : 's'}`; + + let score = 0; + if (title.length >= 30 && title.length <= 80) score += 25; + else if (title.length >= 15) score += 16; + if (description.length >= 180) score += 30; + else if (description.length >= 80) score += 18; + if (tags.length >= 5) score += 15; + else if (tags.length) score += 8; + if (document.getElementById('meta-brand')?.value) score += 8; + if (document.getElementById('meta-category-picker')?.textContent?.trim()) score += 8; + if (parseFloat(document.getElementById('meta-price')?.value)) score += 8; + if (state.photos.length >= 3) score += 6; + score = Math.min(100, score); + const scoreEl = document.getElementById('generator-quality-score'); + const ring = document.getElementById('generator-quality-ring'); + const label = document.getElementById('generator-quality-label'); + if (scoreEl) scoreEl.textContent = `${score}%`; + if (ring) ring.style.setProperty('--quality', `${score}%`); + if (label) { + label.textContent = score >= 85 ? 'Sehr gut' : score >= 65 ? 'Gut' : score >= 40 ? 'Ausbaufähig' : 'Unvollständig'; + label.style.color = score >= 65 ? 'var(--vd-success)' : score >= 40 ? 'var(--vd-warning)' : 'var(--vd-danger)'; + } +} + +async function updateGeneratorFeePreview() { + const price = parseFloat(document.getElementById('meta-price')?.value) || 0; + const feeEl = document.getElementById('fee-info'); + if (!feeEl || !price) { + feeEl?.classList.add('hidden'); + updateGeneratorResultMetrics(); + return; + } + try { + const fees = await api('GET', `/api/fees?platform=${encodeURIComponent(state.platform)}&price=${encodeURIComponent(price)}`); + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${escapeHtml(fees.breakdown || '')}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } catch {} + updateGeneratorResultMetrics(); +} + +async function generateListing() { + const btn = document.getElementById('generate-btn'); + const loading = document.getElementById('loading'); + + btn.disabled = true; + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Analysiert'; previewStatus.className = 'generator-preview-status is-working'; } + document.getElementById('generator-step-ai')?.classList.add('is-active'); + loading.classList.remove('hidden'); + + try { + const body = { + photos: state.photos, + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + variants: state.variants, + template_id: parseInt(document.getElementById('template-select')?.value) || null, + seller_notes: document.getElementById('generator-seller-notes')?.value.trim() || '', + }; + if (state.aiModel) body.ai_model = state.aiModel; + + const data = await api('POST', '/api/generate', body); + + if (data.meta) showMeta(data.meta, data.fees); + + if (data.variants) { + state.allVariants = data.variants; + showVariantTabs(data.variants); + showListing(data.variants[0], data.variants[0].description_html); + } else { + state.allVariants = null; + document.getElementById('variants-section').classList.add('hidden'); + showListing(data.listing, data.description_html); + } + setTimeout(fetchPriceSuggestion, 500); + } catch (err) { + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Fehler'; previewStatus.className = 'generator-preview-status'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + toast('Fehler: ' + err.message); + } finally { + loading.classList.add('hidden'); + btn.disabled = state.photos.length === 0; + updateGeneratorReadiness(); + } +} + +async function showMeta(meta, fees) { + document.getElementById('meta-sku').value = state.currentListing?.sku || ''; + document.getElementById('meta-brand').value = meta.brand || ''; + document.getElementById('meta-size').value = meta.size || ''; + document.getElementById('meta-color').value = meta.color || ''; + document.getElementById('meta-condition').value = meta.condition || ''; + document.getElementById('meta-price').value = meta.suggested_price || ''; + document.getElementById('meta-section').classList.remove('hidden'); + updateGeneratorResultMetrics(); + + let matchedCategory = null; + if (meta.category) { + try { + const matches = await api('GET', `/api/categories/${state.platform}/match?q=${encodeURIComponent(meta.category)}`); + if (matches.length && matches[0].score >= 50) matchedCategory = matches[0].pathStr; + } catch {} + } + metaCategoryPicker = createCategoryPicker('meta-category-picker', state.platform, matchedCategory || meta.category || null); + + const feeEl = document.getElementById('fee-info'); + if (fees) { + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${fees.breakdown}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } else { feeEl.classList.add('hidden'); } +} + +function getEditedMeta() { + return { + brand: document.getElementById('meta-brand').value || null, + category: (metaCategoryPicker ? metaCategoryPicker.getValue() : null) || null, + size: document.getElementById('meta-size').value || null, + color: document.getElementById('meta-color').value || null, + condition: document.getElementById('meta-condition').value || null, + price: parseFloat(document.getElementById('meta-price').value) || null, + storage_location: document.getElementById('meta-storage-location').value || null, + }; +} + +function showVariantTabs(variants) { + const c = document.getElementById('variant-tabs'); + c.innerHTML = ''; + variants.forEach((v, i) => { + const btn = document.createElement('button'); + btn.className = 'variant-tab' + (i === 0 ? ' active' : ''); + btn.textContent = `Variante ${i + 1}`; + btn.addEventListener('click', () => { + c.querySelectorAll('.variant-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showListing(v, v.description_html); + }); + c.appendChild(btn); + }); + document.getElementById('variants-section').classList.remove('hidden'); +} + +function showListing(listing, platformHtml) { + state.currentListing = listing; + const editorHtml = listing.id && listing.description_html + ? sanitizeRichHtmlClient(listing.description_html) + : plainTextToRichHtml(listing.description || ''); + state.currentHtml = platformHtml || ''; + + const skuEl = document.getElementById('meta-sku'); + if (skuEl && listing.sku) skuEl.value = listing.sku; + document.getElementById('result-title').value = listing.title || ''; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, editorHtml); + htmlSyncState.generatorLock = false; + document.getElementById('html-source').value = getEditorHtml(editors.result); + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml || editorHtml); + document.getElementById('html-preview-wrap')?.classList.remove('hidden'); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + document.getElementById('result-tags').value = tags; + const plat = state.platforms.find(p => p.id === state.platform); + document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags'; + document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen'; + document.getElementById('generator-preview-empty')?.classList.add('hidden'); + document.getElementById('result-section').classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Bereit zur Prüfung'; previewStatus.className = 'generator-preview-status is-ready'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + document.getElementById('generator-step-ai')?.classList.add('is-complete'); + document.getElementById('generator-step-result')?.classList.add('is-active'); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); +} + +// --- Copy --- +function setupCopy() { + document.querySelectorAll('.copy-btn[data-target]').forEach(btn => { + btn.addEventListener('click', () => { + navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value); + toast('Kopiert!'); + }); + }); + document.getElementById('copy-desc-text').addEventListener('click', () => { + if (editors.result) { + navigator.clipboard.writeText(editors.result.getText().trim()); + toast('Text kopiert!'); + } + }); + document.getElementById('copy-desc-html').addEventListener('click', () => { + const src = document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Editor-HTML kopiert!'); + } + }); + document.getElementById('copy-platform-html')?.addEventListener('click', () => { + const src = state.currentHtml || document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Plattform-HTML kopiert!'); + } + }); + document.getElementById('format-html-source')?.addEventListener('click', () => { + const source = document.getElementById('html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, safe); + htmlSyncState.generatorLock = false; + refreshGeneratorPlatformPreview(); + toast('HTML bereinigt und synchronisiert'); + }); + document.getElementById('copy-all-btn').addEventListener('click', () => { + const t = document.getElementById('result-title').value; + const d = editors.result ? editors.result.getText().trim() : ''; + const tg = document.getElementById('result-tags').value; + navigator.clipboard.writeText(`${t}\n\n${d}\n\n${tg}`); + toast('Alles kopiert!'); + }); + document.getElementById('save-btn').addEventListener('click', async () => { + if (!state.currentListing) return; + const tags = document.getElementById('result-tags').value.split(',').map(t => t.trim()).filter(Boolean); + const meta = getEditedMeta(); + const descriptionHtml = getEditorHtml(editors.result); + const description = richHtmlToText(descriptionHtml); + const payload = { + title: document.getElementById('result-title').value, + description, + description_html: descriptionHtml, + tags, + photos: [...state.photos], + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + ...meta, + }; + try { + if (state.currentListing.id) { + await api('PUT', `/api/listings/${state.currentListing.id}`, payload); + toast('Gespeichert!'); + } else { + await api('POST', '/api/listings', { + ...state.currentListing, + ...payload, + }); + toast('Listing hinzugefügt!'); + notify('Vendoo', `"${payload.title}" gespeichert`); + resetGenerator(); + } + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function resetGenerator() { + state.currentListing = null; + state.currentHtml = ''; + state.allVariants = null; + state.photos = []; + state.aiModelPhotos = []; + metaCategoryPicker = null; + document.getElementById('photo-preview').innerHTML = ''; + document.getElementById('image-tools').classList.add('hidden'); + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen'; + document.getElementById('generate-btn').disabled = true; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + document.getElementById('template-select').value = ''; + const notes = document.getElementById('generator-seller-notes'); + if (notes) notes.value = ''; + const notesCount = document.getElementById('generator-notes-count'); + if (notesCount) notesCount.textContent = '0 / 500'; + const templateTags = document.getElementById('generator-template-tags'); + if (templateTags) { templateTags.innerHTML = ''; templateTags.classList.add('hidden'); } + if (editors.result) editors.result.setText(''); + const htmlSource = document.getElementById('html-source'); + if (htmlSource) htmlSource.value = ''; + setSandboxPreview(document.getElementById('html-preview'), ''); + document.getElementById('result-title').value = ''; + document.getElementById('result-tags').value = ''; + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Wartet'; previewStatus.className = 'generator-preview-status'; } + ['generator-step-ai', 'generator-step-result'].forEach(id => { + document.getElementById(id)?.classList.remove('is-active', 'is-complete'); + }); + document.getElementById('generator-step-photos')?.classList.remove('is-complete'); + document.getElementById('generator-step-photos')?.classList.add('is-active'); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +// --- Listings / Historie --- +const historyState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + hiddenColumns: new Set(), +}; + +const HISTORY_COLUMN_STORAGE = 'vendoo-history-columns'; + +function setupHistory() { + const search = document.getElementById('search-input'); + const platform = document.getElementById('filter-platform'); + const status = document.getElementById('filter-status'); + const period = document.getElementById('history-period'); + const dateFrom = document.getElementById('filter-date-from'); + const dateTo = document.getElementById('filter-date-to'); + + search.addEventListener('input', debounce(() => { state.historyPage = 1; loadHistory(); }, 300)); + platform.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + status.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + period.addEventListener('change', () => { + applyHistoryPeriod(period.value); + state.historyPage = 1; + loadHistory(); + }); + dateFrom.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + dateTo.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + + document.getElementById('history-reset-filters').addEventListener('click', () => { + search.value = ''; + platform.value = ''; + status.value = ''; + period.value = '90'; + applyHistoryPeriod('90'); + state.historyPage = 1; + loadHistory(); + }); + + document.getElementById('back-to-list').addEventListener('click', () => setHistoryDetailMode(false)); + + document.getElementById('history-select-all').addEventListener('change', e => { + const checked = e.target.checked; + historyState.listings.forEach(listing => { + if (checked) historyState.selected.add(listing.id); + else historyState.selected.delete(listing.id); + }); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = checked; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.toggle('selected', checked)); + updateHistoryToolbar(); + }); + + document.getElementById('history-clear-selection').addEventListener('click', () => { + historyState.selected.clear(); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = false; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.remove('selected')); + document.getElementById('history-select-all').checked = false; + updateHistoryToolbar(); + }); + + document.getElementById('history-delete-selected').addEventListener('click', deleteSelectedHistoryListings); + document.getElementById('history-delete-all').addEventListener('click', async () => { + if (!historyState.allFiltered.length) return; + if (!confirm(`Alle ${historyState.allFiltered.length} gefilterten Listings in den Papierkorb verschieben?`)) return; + for (const listing of historyState.allFiltered) await api('DELETE', `/api/listings/${listing.id}`); + historyState.selected.clear(); + toast('Alle gefilterten Listings wurden in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + }); + + document.getElementById('history-bulk-status').addEventListener('click', bulkUpdateHistoryStatus); + document.getElementById('history-price-selected').addEventListener('click', bulkUpdateHistoryPrice); + document.getElementById('history-duplicate-selected').addEventListener('click', bulkDuplicateHistoryListings); + document.getElementById('history-edit-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (ids.length !== 1) { toast('Zum Nachbearbeiten bitte genau ein Listing auswählen'); return; } + showDetail(ids[0]); + }); + document.getElementById('history-publish-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (!ids.length) return; + publishState.selected = new Set(ids); + switchToTab('publish'); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} für Publish ausgewählt`); + }); + document.getElementById('history-print-labels').addEventListener('click', () => { + const selected = historyState.allFiltered.filter(listing => historyState.selected.has(listing.id)); + if (selected.length) printLabels(selected); + }); + + document.getElementById('export-csv-btn').addEventListener('click', () => { + const params = new URLSearchParams(); + if (platform.value) params.set('platform', platform.value); + if (status.value) params.set('status', status.value); + window.open(`/api/export/csv?${params}`, '_blank'); + closeHistoryPopovers(); + }); + document.getElementById('export-json-btn').addEventListener('click', () => { + const data = historyState.allFiltered; + if (!data.length) { toast('Keine Listings zum Exportieren'); return; } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const anchor = document.createElement('a'); + anchor.href = URL.createObjectURL(blob); + anchor.download = `vendoo-export-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(anchor.href); + toast(`${data.length} Listings als JSON exportiert`); + closeHistoryPopovers(); + }); + + setupHistoryPopovers(); + setupHistoryColumns(); + + const pageSizeSelect = document.getElementById('history-page-size-select'); + pageSizeSelect.value = String(state.historyPageSize); + pageSizeSelect.addEventListener('change', () => { + state.historyPageSize = Number(pageSizeSelect.value); + state.historyPage = 1; + loadHistory(); + }); + + setupPagination('history'); + applyHistoryPeriod(period.value || '90'); +} + +function applyHistoryPeriod(value) { + const custom = document.getElementById('history-custom-dates'); + const fromInput = document.getElementById('filter-date-from'); + const toInput = document.getElementById('filter-date-to'); + custom.classList.toggle('hidden', value !== 'custom'); + if (value === 'custom') return; + + const today = new Date(); + const iso = date => date.toISOString().slice(0, 10); + toInput.value = value === 'all' ? '' : iso(today); + + if (value === 'all') { + fromInput.value = ''; + } else if (value === 'year') { + fromInput.value = `${today.getFullYear()}-01-01`; + } else { + const days = Number(value) || 90; + const from = new Date(today); + from.setDate(from.getDate() - days + 1); + fromInput.value = iso(from); + } +} + +function setupHistoryPopovers() { + const exportButton = document.getElementById('history-export-toggle'); + const exportMenu = document.getElementById('history-export-menu'); + const columnsButton = document.getElementById('history-columns-btn'); + const columnsPanel = document.getElementById('history-columns-panel'); + + exportButton.addEventListener('click', event => { + event.stopPropagation(); + const open = exportMenu.classList.toggle('hidden') === false; + columnsPanel.classList.add('hidden'); + exportButton.setAttribute('aria-expanded', String(open)); + columnsButton.setAttribute('aria-expanded', 'false'); + }); + columnsButton.addEventListener('click', event => { + event.stopPropagation(); + const open = columnsPanel.classList.toggle('hidden') === false; + exportMenu.classList.add('hidden'); + columnsButton.setAttribute('aria-expanded', String(open)); + exportButton.setAttribute('aria-expanded', 'false'); + }); + exportMenu.addEventListener('click', event => event.stopPropagation()); + columnsPanel.addEventListener('click', event => event.stopPropagation()); + document.addEventListener('click', closeHistoryPopovers); +} + +function closeHistoryPopovers() { + document.getElementById('history-export-menu')?.classList.add('hidden'); + document.getElementById('history-columns-panel')?.classList.add('hidden'); + document.getElementById('history-export-toggle')?.setAttribute('aria-expanded', 'false'); + document.getElementById('history-columns-btn')?.setAttribute('aria-expanded', 'false'); + document.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); +} + +function setupHistoryColumns() { + try { + const saved = JSON.parse(localStorage.getItem(HISTORY_COLUMN_STORAGE) || '[]'); + historyState.hiddenColumns = new Set(Array.isArray(saved) ? saved : []); + } catch { historyState.hiddenColumns = new Set(); } + + document.querySelectorAll('[data-history-column]').forEach(input => { + input.checked = !historyState.hiddenColumns.has(input.dataset.historyColumn); + input.addEventListener('change', () => { + const column = input.dataset.historyColumn; + if (input.checked) historyState.hiddenColumns.delete(column); + else historyState.hiddenColumns.add(column); + localStorage.setItem(HISTORY_COLUMN_STORAGE, JSON.stringify([...historyState.hiddenColumns])); + applyHistoryColumns(); + }); + }); + applyHistoryColumns(); +} + +function applyHistoryColumns() { + const table = document.getElementById('history-table'); + if (!table) return; + ['platform', 'provider', 'price', 'status', 'location', 'published'].forEach(column => { + table.classList.toggle(`history-hide-${column}`, historyState.hiddenColumns.has(column)); + }); +} + +function setHistoryDetailMode(enabled) { + document.querySelector('.listings-heading')?.classList.toggle('hidden', enabled); + document.querySelector('.history-filterbar')?.classList.toggle('hidden', enabled); + document.querySelector('.history-table-shell')?.classList.toggle('hidden', enabled); + document.getElementById('history-detail')?.classList.toggle('hidden', !enabled); +} + +async function deleteSelectedHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + if (!confirm(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschieben?`)) return; + for (const id of ids) await api('DELETE', `/api/listings/${id}`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschoben`); + await loadHistory(); + updateTrashBadge(); +} + +async function bulkUpdateHistoryStatus() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Status: aktiv, verkauft oder reserviert'); + if (!answer) return; + const normalized = answer.trim().toLowerCase(); + const map = { aktiv: 'active', active: 'active', verkauft: 'sold', sold: 'sold', reserviert: 'reserved', reserved: 'reserved' }; + const newStatus = map[normalized]; + if (!newStatus) { toast('Unbekannter Status'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { status: newStatus }))); + toast(`Status für ${ids.length} Listings aktualisiert`); + loadHistory(); +} + +async function bulkUpdateHistoryPrice() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Preis in Euro für alle ausgewählten Listings:'); + if (answer === null) return; + const price = Number(String(answer).replace(',', '.')); + if (!Number.isFinite(price) || price < 0) { toast('Bitte einen gültigen Preis eingeben'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { price }))); + toast(`Preis für ${ids.length} Listings aktualisiert`); + loadHistory(); +} + +async function bulkDuplicateHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + for (const id of ids) await api('POST', `/api/listings/${id}/duplicate`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} dupliziert`); + loadHistory(); +} + +function setupPagination(prefix) { + const topBar = document.getElementById(`${prefix}-pagination-top`); + if (!topBar) return; + topBar.querySelectorAll('.page-size-btn').forEach(btn => { + btn.addEventListener('click', () => { + topBar.querySelectorAll('.page-size-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const size = parseInt(btn.dataset.size); + if (prefix === 'history') { state.historyPageSize = size; state.historyPage = 1; loadHistory(); } + else { state.publishPageSize = size; state.publishPage = 1; loadPublishList(); } + }); + }); + document.getElementById(`${prefix}-prev`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); + document.getElementById(`${prefix}-prev-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); +} + +function updatePaginationUI(prefix, page, totalPages, totalItems) { + const info = `Seite ${page} von ${totalPages} (${totalItems} Einträge)`; + document.getElementById(`${prefix}-page-info`)?.replaceChildren(document.createTextNode(info)); + document.getElementById(`${prefix}-page-info-bottom`)?.replaceChildren(document.createTextNode(info)); + + const prevDisabled = page <= 1; + const nextDisabled = page >= totalPages; + const prev = document.getElementById(`${prefix}-prev`); + const next = document.getElementById(`${prefix}-next`); + const prevBottom = document.getElementById(`${prefix}-prev-bottom`); + const nextBottom = document.getElementById(`${prefix}-next-bottom`); + if (prev) prev.disabled = prevDisabled; + if (next) next.disabled = nextDisabled; + if (prevBottom) prevBottom.disabled = prevDisabled; + if (nextBottom) nextBottom.disabled = nextDisabled; + + if (prefix === 'history') { + const totalInfo = document.getElementById('history-total-info'); + if (totalInfo) totalInfo.textContent = `von ${totalItems} Einträgen`; + renderHistoryPageButtons(page, totalPages); + } +} + +function renderHistoryPageButtons(page, totalPages) { + const container = document.getElementById('history-page-buttons'); + if (!container) return; + const pages = new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2]); + const valid = [...pages].filter(value => value >= 1 && value <= totalPages).sort((a, b) => a - b); + const parts = []; + let previous = 0; + for (const value of valid) { + if (previous && value - previous > 1) parts.push(''); + parts.push(``); + previous = value; + } + container.innerHTML = parts.join(''); + container.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => { + state.historyPage = Number(button.dataset.page); + loadHistory(); + })); +} + +function paginateList(allItems, page, pageSize) { + if (!pageSize || pageSize <= 0) return { items: allItems, page: 1, totalPages: 1 }; + const totalPages = Math.max(1, Math.ceil(allItems.length / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * pageSize; + return { items: allItems.slice(start, start + pageSize), page: safePage, totalPages }; +} + +async function loadHistory() { + const query = document.getElementById('search-input').value.trim(); + const platform = document.getElementById('filter-platform').value; + const status = document.getElementById('filter-status').value; + const dateFrom = document.getElementById('filter-date-from').value; + const dateTo = document.getElementById('filter-date-to').value; + + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + let listings = await api('GET', `/api/listings?${params.toString()}`); + + if (dateFrom) { + const from = new Date(`${dateFrom}T00:00:00`); + listings = listings.filter(listing => historyParseDate(listing.created_at) >= from); + } + if (dateTo) { + const to = new Date(`${dateTo}T23:59:59.999`); + listings = listings.filter(listing => historyParseDate(listing.created_at) <= to); + } + + historyState.allFiltered = listings; + const { items, page, totalPages } = paginateList(listings, state.historyPage, state.historyPageSize); + state.historyPage = page; + historyState.listings = items; + updatePaginationUI('history', page, totalPages, listings.length); + + if (items.length) { + const ids = items.map(listing => listing.id).join(','); + try { historyState.publishStatus = await api('GET', `/api/publish/status?ids=${ids}`); } + catch { historyState.publishStatus = {}; } + } else { + historyState.publishStatus = {}; + } + + renderHistoryList(items); +} + +function historyParseDate(value) { + if (!value) return new Date(0); + const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value.replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? new Date(0) : date; +} + +function historyFormatDate(value, withTime = false) { + const date = historyParseDate(value); + if (!date.getTime()) return '—'; + return date.toLocaleString('de-DE', withTime + ? { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' } + : { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function historyProviderLabel(provider) { + return ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || '—'; +} + +function historyStatusMeta(status) { + return ({ + active: { label: 'Aktiv', className: 'active' }, + sold: { label: 'Verkauft', className: 'sold' }, + reserved: { label: 'Reserviert', className: 'reserved' }, + })[status] || { label: status || 'Aktiv', className: 'active' }; +} + +function historyPublishSummary(listing) { + const statuses = historyState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return 'Nicht veröffentlicht'; + return entries.slice(0, 2).map(([platform, status]) => { + const meta = { + published: ['Live', 'published'], + copied: ['Kopiert', 'copied'], + pending: ['Ausstehend', 'pending'], + failed: ['Fehler', 'failed'], + }[status] || [status, 'pending']; + return `${escapeHtml(platformLabel(platform))}${escapeHtml(meta[0])}`; + }).join(''); +} + +function renderHistoryList(listings) { + const container = document.getElementById('history-list'); + if (!listings.length) { + container.innerHTML = `
                                              +
                                              +

                                              Keine Listings gefunden

                                              +

                                              Ändere die Filter oder erstelle ein neues Listing.

                                              + +
                                              `; + container.querySelector('[data-empty-new-listing]')?.addEventListener('click', () => switchToTab('generator')); + updateHistoryToolbar(); + applyHistoryColumns(); + return; + } + + container.innerHTML = listings.map(listing => { + const checked = historyState.selected.has(listing.id); + const photo = listing.photos?.[0] + ? `` + : ''; + const status = historyStatusMeta(listing.status || 'active'); + const provider = historyProviderLabel(listing.ai_provider); + const platform = platformLabel(listing.platform); + const price = listing.price !== null && listing.price !== undefined ? formatCurrency(listing.price) : '—'; + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + + return `
                                              +
                                              +
                                              + ${photo} +
                                              + +
                                              SKU  ${escapeHtml(sku)}
                                              +
                                              +
                                              +
                                              ${escapeHtml(platform)}${escapeHtml((listing.language || 'de').toUpperCase())}
                                              +
                                              ${escapeHtml(provider.slice(0, 1))}${escapeHtml(provider)}Vision AI
                                              +
                                              ${escapeHtml(price)}Festpreis
                                              +
                                              + + Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))} +
                                              +
                                              ${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}${escapeHtml(listing.category || '—')}
                                              +
                                              ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}
                                              ${historyPublishSummary(listing)}
                                              +
                                              + + +
                                              +
                                              `; + }).join(''); + + container.querySelectorAll('.history-row-select').forEach(checkbox => { + checkbox.addEventListener('click', event => event.stopPropagation()); + checkbox.addEventListener('change', () => { + const id = Number(checkbox.dataset.id); + if (checkbox.checked) historyState.selected.add(id); + else historyState.selected.delete(id); + checkbox.closest('.history-row')?.classList.toggle('selected', checkbox.checked); + updateHistoryToolbar(); + }); + }); + + container.querySelectorAll('[data-open-listing]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + showDetail(Number(button.dataset.openListing)); + })); + + container.querySelectorAll('.history-row').forEach(row => row.addEventListener('dblclick', event => { + if (!event.target.closest('button, input, select')) showDetail(Number(row.dataset.id)); + })); + + container.querySelectorAll('.history-status-select').forEach(select => { + select.addEventListener('click', event => event.stopPropagation()); + select.addEventListener('change', async () => { + await api('PUT', `/api/listings/${select.dataset.id}`, { status: select.value }); + toast('Status aktualisiert'); + loadHistory(); + }); + }); + + container.querySelectorAll('[data-row-menu]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + const panel = container.querySelector(`[data-row-menu-panel="${button.dataset.rowMenu}"]`); + const willOpen = panel?.classList.contains('hidden'); + container.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); + if (willOpen) panel?.classList.remove('hidden'); + })); + + container.querySelectorAll('[data-row-action]').forEach(button => button.addEventListener('click', async event => { + event.stopPropagation(); + const id = Number(button.dataset.id); + const action = button.dataset.rowAction; + if (action === 'open') showDetail(id); + if (action === 'duplicate') { + const copy = await api('POST', `/api/listings/${id}/duplicate`); + toast(`Kopie erstellt: „${copy.title}“`); + loadHistory(); + } + if (action === 'delete') { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${id}`); + historyState.selected.delete(id); + toast('Listing in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + } + })); + + document.getElementById('history-select-all').checked = listings.every(listing => historyState.selected.has(listing.id)); + updateHistoryToolbar(); + applyHistoryColumns(); +} + +function updateHistoryToolbar() { + const count = historyState.selected.size; + const countLabel = document.getElementById('history-selected-count'); + if (countLabel) countLabel.textContent = `${count} ausgewählt`; + ['history-delete-selected', 'history-bulk-status', 'history-print-labels', 'history-publish-selected', 'history-edit-selected', 'history-price-selected', 'history-duplicate-selected'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + const clear = document.getElementById('history-clear-selection'); + clear?.classList.toggle('hidden', count === 0); + document.getElementById('history-batch-bar')?.classList.toggle('has-selection', count > 0); + const selectAll = document.getElementById('history-select-all'); + if (selectAll) { + const selectedOnPage = historyState.listings.filter(listing => historyState.selected.has(listing.id)).length; + selectAll.checked = historyState.listings.length > 0 && selectedOnPage === historyState.listings.length; + selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < historyState.listings.length; + } +} + +let detailRenderSequence = 0; +let detailPhotoDragIndex = -1; + +function detailPlatformOptions(selected) { + return state.platforms.map(platform => ``).join(''); +} + +function detailProviderOptions(selected) { + return state.providers.map(provider => ``).join(''); +} + +function detailLanguageOptions(selected) { + const languages = [['de', 'Deutsch'], ['en', 'English'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['es', 'Español'], ['it', 'Italiano'], ['pt', 'Português']]; + return languages.map(([value, label]) => ``).join(''); +} + +function getDetailListingForHtml() { + if (!detailEditorState) return null; + const tags = (document.getElementById('detail-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || '', + description: editors.detail ? editors.detail.getText().trim() : '', + description_html: getEditorHtml(editors.detail), + tags, + brand: document.getElementById('detail-brand')?.value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size')?.value || null, + color: document.getElementById('detail-color')?.value || null, + condition: document.getElementById('detail-condition')?.value || null, + }; +} + +const refreshDetailPlatformPreview = debounce(async () => { + const listing = getDetailListingForHtml(); + if (!listing) return; + const sequence = ++detailRenderSequence; + setHtmlSyncStatus('detail-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const data = await api('POST', '/api/html-render', { platform: listing.platform, listing }); + if (sequence !== detailRenderSequence) return; + detailEditorState.platformHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('detail-html-preview'), detailEditorState.platformHtml); + setHtmlSyncStatus('detail-html-sync-status', 'Synchron', 'ok'); + } catch { + if (sequence !== detailRenderSequence) return; + setSandboxPreview(document.getElementById('detail-html-preview'), listing.description_html); + setHtmlSyncStatus('detail-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupDetailHtmlSync() { + const editor = editors.detail; + const source = document.getElementById('detail-html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.detailLock) return; + source.value = getEditorHtml(editor); + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }); + + const applySource = debounce(() => { + htmlSyncState.detailLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.detailLock = false; + source.value = safe; + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('detail-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('detail-html-preview'), sanitizeRichHtmlClient(source.value)); + applySource(); + }); +} + +function renderDetailPhotos() { + if (!detailEditorState) return; + const photos = detailEditorState.photos; + const hero = document.getElementById('detail-photo-hero'); + const empty = document.getElementById('detail-photo-empty'); + const strip = document.getElementById('detail-photo-strip'); + const count = document.getElementById('detail-photo-count'); + const activeIndex = Math.min(detailEditorState.activePhotoIndex || 0, Math.max(0, photos.length - 1)); + detailEditorState.activePhotoIndex = activeIndex; + if (count) count.textContent = `${photos.length} / 10`; + if (hero && empty) { + if (photos.length) { + hero.src = photoUrl(photos[activeIndex]); + hero.alt = `Produktfoto ${activeIndex + 1}`; + hero.classList.remove('hidden'); + empty.classList.add('hidden'); + } else { + hero.removeAttribute('src'); + hero.classList.add('hidden'); + empty.classList.remove('hidden'); + } + } + ['detail-edit-photo', 'detail-set-cover', 'detail-remove-photo'].forEach(id => { + const button = document.getElementById(id); + if (button) button.disabled = photos.length === 0; + }); + if (!strip) return; + strip.innerHTML = photos.map((photo, index) => ` + `).join(''); + strip.querySelectorAll('[data-detail-photo-index]').forEach(button => { + const index = Number(button.dataset.detailPhotoIndex); + button.addEventListener('click', () => { detailEditorState.activePhotoIndex = index; renderDetailPhotos(); }); + button.addEventListener('dragstart', event => { detailPhotoDragIndex = index; event.dataTransfer.effectAllowed = 'move'; button.classList.add('is-dragging'); }); + button.addEventListener('dragend', () => { detailPhotoDragIndex = -1; button.classList.remove('is-dragging'); }); + button.addEventListener('dragover', event => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }); + button.addEventListener('drop', event => { + event.preventDefault(); + if (detailPhotoDragIndex < 0 || detailPhotoDragIndex === index) return; + const [moved] = detailEditorState.photos.splice(detailPhotoDragIndex, 1); + detailEditorState.photos.splice(index, 0, moved); + detailEditorState.activePhotoIndex = index; + detailPhotoDragIndex = -1; + renderDetailPhotos(); + }); + }); +} + +async function uploadDetailPhotos(files) { + if (!detailEditorState) return; + const available = Math.max(0, 10 - detailEditorState.photos.length); + const accepted = Array.from(files || []).filter(file => file.type.startsWith('image/')).slice(0, available); + if (!available) return toast('Maximal 10 Fotos pro Listing'); + if (!accepted.length) return; + const platform = document.getElementById('detail-platform')?.value || detailEditorState.listing.platform || 'allgemein'; + const formData = new FormData(); + formData.append('platform', platform); + accepted.forEach(file => formData.append('photos', file)); + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, { + method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen'); + detailEditorState.photos.push(...(data.filenames || [])); + detailEditorState.activePhotoIndex = Math.max(0, detailEditorState.photos.length - (data.filenames || []).length); + renderDetailPhotos(); + toast(`${(data.filenames || []).length} Foto${(data.filenames || []).length === 1 ? '' : 's'} hinzugefügt`); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function setupDetailPhotoActions() { + const fileInput = document.getElementById('detail-photo-input'); + document.getElementById('detail-add-photo')?.addEventListener('click', () => fileInput?.click()); + document.getElementById('detail-mobile-photo')?.addEventListener('click', () => openMobileUpload('listing', detailEditorState?.listing?.id)); + fileInput?.addEventListener('change', () => { uploadDetailPhotos(fileInput.files); fileInput.value = ''; }); + document.getElementById('detail-edit-photo')?.addEventListener('click', () => openPhotoEditor(detailEditorState.activePhotoIndex, 'detail')); + document.getElementById('detail-set-cover')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (index <= 0 || !detailEditorState.photos[index]) return; + const [photo] = detailEditorState.photos.splice(index, 1); + detailEditorState.photos.unshift(photo); + detailEditorState.activePhotoIndex = 0; + renderDetailPhotos(); + toast('Coverfoto festgelegt'); + }); + document.getElementById('detail-download-photos')?.addEventListener('click', () => downloadPublishPhotos(detailEditorState?.photos || [])); + document.getElementById('detail-remove-photo')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (!detailEditorState.photos[index]) return; + detailEditorState.photos.splice(index, 1); + detailEditorState.activePhotoIndex = Math.max(0, Math.min(index, detailEditorState.photos.length - 1)); + renderDetailPhotos(); + }); +} + +async function showDetail(id) { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + toast('Ungültige Listing-ID'); + return; + } + + const historySection = document.getElementById('history'); + if (!historySection?.classList.contains('active')) { + switchToTab('history'); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + const l = await api('GET', `/api/listings/${numericId}`); + const tags = Array.isArray(l.tags) ? l.tags.join(', ') : (l.tags || ''); + const skuVal = l.sku || `VD-${String(l.id).padStart(4, '0')}`; + detailEditorState = { + listing: l, + photos: [...(Array.isArray(l.photos) ? l.photos : [])], + activePhotoIndex: 0, + categoryPicker: null, + platformHtml: '', + aiModelPhotos: [], + aiModelGenerations: [], + }; + + document.getElementById('detail-content').innerHTML = ` +
                                              +
                                              +
                                              + Artikel vollständig bearbeiten +

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

                                              +

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

                                              +
                                              +
                                              + + +
                                              +
                                              + +
                                              + + +
                                              +
                                              +
                                              02

                                              Listing-Daten

                                              +
                                              + + + + + + +
                                              + + +
                                              + +
                                              +
                                              03

                                              Beschreibung

                                              0 Zeichen
                                              +
                                              WYSIWYGFormatierte Bearbeitung
                                              +
                                              +
                                              +
                                              + + +
                                              + Synchron +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              +
                                              + +
                                              +
                                              04

                                              Eigenschaften & Preis

                                              +
                                              + + + + + + +
                                              + +
                                              +
                                              + + +
                                              +
                                              `; + + editors.detail = initQuill('#detail-description-editor', 'Beschreibung...'); + const initialHtml = l.description_html ? sanitizeRichHtmlClient(l.description_html) : plainTextToRichHtml(l.description || ''); + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, initialHtml); + htmlSyncState.detailLock = false; + document.getElementById('detail-html-source').value = getEditorHtml(editors.detail); + document.getElementById('detail-description-count').textContent = `${editors.detail ? editors.detail.getText().trim().length : 0} Zeichen`; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', l.platform, l.category || null); + setupDetailHtmlSync(); + bindHtmlViewTabs('detail'); + setupDetailPhotoActions(); + document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos); + renderDetailPhotos(); + renderAiModelGrid('detail', []); + addQuillTooltips(); + refreshDetailPlatformPreview(); + loadDetailAiModelPhotos(numericId); + loadDetailAiModelGenerations(numericId); + + if (typeof QRious !== 'undefined') { + new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' }); + } + + const refreshFields = ['detail-title', 'detail-tags', 'detail-brand', 'detail-size', 'detail-color', 'detail-condition']; + refreshFields.forEach(fieldId => document.getElementById(fieldId)?.addEventListener('input', refreshDetailPlatformPreview)); + document.getElementById('detail-title')?.addEventListener('input', event => { + document.getElementById('detail-title-count').textContent = `${event.target.value.length} / 120`; + }); + document.getElementById('detail-platform')?.addEventListener('change', event => { + const currentCategory = detailEditorState.categoryPicker?.getValue?.() || null; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', event.target.value, currentCategory); + refreshDetailPlatformPreview(); + }); + document.getElementById('detail-sku')?.addEventListener('input', event => { document.getElementById('detail-sku-display').textContent = event.target.value || 'Ohne SKU'; }); + + document.getElementById('detail-copy-editor-html').addEventListener('click', () => { navigator.clipboard.writeText(getEditorHtml(editors.detail)); toast('Editor-HTML kopiert'); }); + document.getElementById('detail-copy-platform-html').addEventListener('click', () => { navigator.clipboard.writeText(detailEditorState.platformHtml || getEditorHtml(editors.detail)); toast('Plattform-HTML kopiert'); }); + document.getElementById('detail-clean-html').addEventListener('click', () => { + const source = document.getElementById('detail-html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, safe); + htmlSyncState.detailLock = false; + refreshDetailPlatformPreview(); + toast('HTML bereinigt'); + }); + + document.getElementById('detail-print-label').addEventListener('click', () => printLabels([{ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, price: parseFloat(document.getElementById('detail-price').value) || null }])); + document.getElementById('detail-print-sheet').addEventListener('click', () => printDatasheet({ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, description: editors.detail?.getText().trim() || '', price: parseFloat(document.getElementById('detail-price').value) || null })); + + document.getElementById('detail-save').addEventListener('click', async () => { + const descriptionHtml = getEditorHtml(editors.detail); + const update = { + platform: document.getElementById('detail-platform').value, + ai_provider: document.getElementById('detail-ai-provider').value, + language: document.getElementById('detail-language').value, + title: document.getElementById('detail-title').value, + description: richHtmlToText(descriptionHtml), + description_html: descriptionHtml, + tags: document.getElementById('detail-tags').value.split(',').map(tag => tag.trim()).filter(Boolean), + photos: [...detailEditorState.photos], + seller_notes: document.getElementById('detail-seller-notes').value || null, + status: document.getElementById('detail-status').value, + sku: document.getElementById('detail-sku').value || null, + brand: document.getElementById('detail-brand').value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size').value || null, + color: document.getElementById('detail-color').value || null, + condition: document.getElementById('detail-condition').value || null, + price: parseFloat(document.getElementById('detail-price').value) || null, + storage_location: document.getElementById('detail-storage-location').value || null, + }; + try { + const saved = await api('PUT', `/api/listings/${numericId}`, update); + detailEditorState.listing = saved; + toast('Alle Änderungen gespeichert'); + loadHistory(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('detail-copy-all').addEventListener('click', () => { + const content = `${document.getElementById('detail-title').value}\n\n${editors.detail?.getText().trim() || ''}\n\n${document.getElementById('detail-tags').value}`; + navigator.clipboard.writeText(content); + toast('Alles kopiert!'); + }); + + document.getElementById('detail-delete').addEventListener('click', async () => { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${numericId}`); + toast('In Papierkorb verschoben'); + updateTrashBadge(); + document.getElementById('back-to-list').click(); + loadHistory(); + }); + + setHistoryDetailMode(true); + document.getElementById('history-detail')?.scrollIntoView({ block: 'start' }); +} + +// --- Templates --- +function setupTemplates() { + document.getElementById('add-template-btn').addEventListener('click', () => { + state.editingTemplateId = null; + document.getElementById('template-form-title').textContent = 'Neue Vorlage'; + document.getElementById('tpl-name').value = ''; + document.getElementById('tpl-platform').value = ''; + document.getElementById('tpl-language').value = 'de'; + if (editors.tplNotes) editors.tplNotes.setText(''); + document.getElementById('tpl-tags').value = ''; + document.getElementById('template-form').classList.remove('hidden'); + }); + document.getElementById('cancel-template-btn').addEventListener('click', () => { + document.getElementById('template-form').classList.add('hidden'); + }); + document.getElementById('save-template-btn').addEventListener('click', async () => { + const data = { + name: document.getElementById('tpl-name').value, + platform: document.getElementById('tpl-platform').value || null, + language: document.getElementById('tpl-language').value, + seller_notes: editors.tplNotes ? editors.tplNotes.getText().trim() : '', + default_tags: document.getElementById('tpl-tags').value.split(',').map(t => t.trim()).filter(Boolean), + }; + if (!data.name) { toast('Name ist Pflichtfeld'); return; } + try { + if (state.editingTemplateId) { + await api('PUT', `/api/templates/${state.editingTemplateId}`, data); + } else { + await api('POST', '/api/templates', data); + } + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('template-form').classList.add('hidden'); + toast('Vorlage gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function renderTemplateList() { + const c = document.getElementById('template-list'); + if (!state.templates.length) { c.innerHTML = '
                                              Keine Vorlagen vorhanden
                                              '; return; } + c.innerHTML = state.templates.map(t => { + const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen'; + return `
                                              +
                                              +
                                              ${escapeHtml(t.name)}
                                              +
                                              ${pName} · ${t.language.toUpperCase()}
                                              +
                                              +
                                              + + +
                                              +
                                              `; + }).join(''); + + c.querySelectorAll('.tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const t = state.templates.find(x => x.id === parseInt(btn.dataset.id)); + if (!t) return; + state.editingTemplateId = t.id; + document.getElementById('template-form-title').textContent = 'Vorlage bearbeiten'; + document.getElementById('tpl-name').value = t.name; + document.getElementById('tpl-platform').value = t.platform || ''; + document.getElementById('tpl-language').value = t.language || 'de'; + if (editors.tplNotes) editors.tplNotes.setText(t.seller_notes || ''); + document.getElementById('tpl-tags').value = (t.default_tags || []).join(', '); + document.getElementById('template-form').classList.remove('hidden'); + }); + }); + c.querySelectorAll('.tpl-delete').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Vorlage löschen?')) return; + await api('DELETE', `/api/templates/${btn.dataset.id}`); + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + toast('Vorlage gelöscht'); + }); + }); +} + +function updateTemplateDropdown() { + const sel = document.getElementById('template-select'); + sel.innerHTML = '' + + state.templates.map(t => ``).join(''); +} + +function populateTemplateSelects(platforms) { + const sel = document.getElementById('tpl-platform'); + sel.innerHTML = '' + + platforms.map(p => ``).join(''); +} + +// --- Inventory / Storage --- +async function loadInventory() { + const locations = await api('GET', '/api/inventory/locations'); + const unassigned = await api('GET', '/api/inventory/unassigned'); + + const statsEl = document.getElementById('inv-stats'); + const totalItems = locations.reduce((s, l) => s + l.count, 0) + unassigned.length; + const totalActive = locations.reduce((s, l) => s + l.active, 0) + unassigned.filter(x => x.status === 'active').length; + const totalValue = locations.reduce((s, l) => s + l.total_value, 0) + unassigned.filter(x => x.status === 'active').reduce((s, x) => s + (x.price || 0), 0); + statsEl.innerHTML = ` +
                                              ${totalItems}
                                              Artikel gesamt
                                              +
                                              ${locations.length}
                                              Lagerorte
                                              +
                                              ${totalActive}
                                              Aktive Artikel
                                              +
                                              ${totalValue.toFixed(0)} €
                                              Lagerwert
                                              `; + + const locsEl = document.getElementById('inv-locations'); + let html = ''; + for (const loc of locations) { + html += `
                                              +
                                              +
                                              +
                                              ${escapeHtml(loc.storage_location)}
                                              +
                                              +
                                              + ${loc.count} Artikel + ${loc.active} aktiv + ${loc.sold ? `${loc.sold} verkauft` : ''} + ${loc.reserved ? `${loc.reserved} reserviert` : ''} +
                                              +
                                              ${loc.total_value.toFixed(0)} €
                                              +
                                              `; + } + if (unassigned.length) { + html += `
                                              +
                                              +
                                              +
                                              Ohne Lagerort
                                              +
                                              +
                                              ${unassigned.length} Artikel
                                              +
                                              `; + } + if (!html) html = '
                                              Noch keine Lagerorte vorhanden. Weise Listings einen Lagerort zu.
                                              '; + locsEl.innerHTML = html; + + document.getElementById('inv-detail').classList.add('hidden'); + locsEl.classList.remove('hidden'); + + locsEl.querySelectorAll('.inv-location-card').forEach(card => { + card.addEventListener('click', () => showInventoryDetail(card.dataset.location)); + }); + + document.getElementById('inv-add-location-btn').onclick = () => { + const name = prompt('Neuer Lagerort (z.B. Regal A1, Karton 5, Schrank oben):'); + if (!name?.trim()) return; + toast(`Lagerort "${name.trim()}" angelegt. Weise jetzt Artikel zu.`); + loadInventory(); + }; +} + +async function showInventoryDetail(location) { + const items = location + ? await api('GET', `/api/inventory/location/${encodeURIComponent(location)}`) + : await api('GET', '/api/inventory/unassigned'); + + document.getElementById('inv-locations').classList.add('hidden'); + const detail = document.getElementById('inv-detail'); + detail.classList.remove('hidden'); + document.getElementById('inv-detail-title').textContent = location || 'Ohne Lagerort'; + + const list = document.getElementById('inv-detail-list'); + if (!items.length) { + list.innerHTML = '
                                              Keine Artikel an diesem Lagerort
                                              '; + } else { + list.innerHTML = items.map(l => { + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const statusCls = l.status === 'sold' ? 'sold' : l.status === 'reserved' ? 'reserved' : ''; + return `
                                              + +
                                              +
                                              ${l.sku ? `${escapeHtml(l.sku)}` : ''} ${escapeHtml(l.title || 'Ohne Titel')}
                                              +
                                              ${pName} · ${l.price ? l.price.toFixed(2) + ' €' : '–'} · ${l.status || 'active'}
                                              +
                                              +
                                              `; + }).join(''); + } + + document.getElementById('inv-back').onclick = () => loadInventory(); + + document.getElementById('inv-rename-btn').onclick = async () => { + if (!location) return toast('Ohne Lagerort kann nicht umbenannt werden'); + const newName = prompt('Lagerort umbenennen:', location); + if (!newName?.trim() || newName.trim() === location) return; + const ids = items.map(l => l.id); + await api('PUT', '/api/inventory/bulk-location', { ids, location: newName.trim() }); + toast(`Umbenannt zu "${newName.trim()}"`); + loadInventory(); + }; + + document.getElementById('inv-move-btn').onclick = async () => { + const checked = [...detail.querySelectorAll('.inv-item-check:checked')].map(c => parseInt(c.dataset.id)); + if (!checked.length) return toast('Bitte Artikel auswählen'); + const dest = prompt('Ziel-Lagerort:'); + if (!dest?.trim()) return; + await api('PUT', '/api/inventory/bulk-location', { ids: checked, location: dest.trim() }); + toast(`${checked.length} Artikel verschoben nach "${dest.trim()}"`); + loadInventory(); + }; +} + +// --- Fee Calculator --- +function setupFeeCalculator() { + document.getElementById('fee-price').addEventListener('input', debounce(updateFeeCalculator, 200)); +} + +async function updateFeeCalculator() { + const price = parseFloat(document.getElementById('fee-price').value) || 0; + if (price <= 0) { document.getElementById('fee-results').innerHTML = ''; return; } + try { + const fees = await api('GET', `/api/fees?price=${price}`); + const c = document.getElementById('fee-results'); + c.innerHTML = Object.entries(fees).map(([id, f]) => { + const name = state.platforms.find(p => p.id === id)?.name || id; + return `
                                              +
                                              +
                                              ${name}
                                              +
                                              ${f.breakdown}
                                              +
                                              +
                                              +
                                              ${f.net.toFixed(2)} €
                                              +
                                              -${f.fees.toFixed(2)} € Gebühren
                                              +
                                              +
                                              `; + }).join(''); + } catch {} +} + +// --- Publish Center --- +const publishState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + queue: [], + scheduled: [], + workflow: null, + integrations: { ebay: null, etsy: null }, + selectedId: null, + view: 'smart', + renderToken: 0, +}; + +function setupPublish() { + const search = document.getElementById('publish-search'); + if (!search) return; + + search.addEventListener('input', debounce(loadPublishList, 250)); + document.getElementById('publish-filter-platform')?.addEventListener('change', loadPublishList); + document.getElementById('publish-filter-status')?.addEventListener('change', loadPublishList); + document.getElementById('publish-sort')?.addEventListener('change', () => { + sortPublishListings(); + renderPublishList(); + }); + document.getElementById('publish-select-all')?.addEventListener('change', event => { + for (const listing of publishState.listings) { + if (event.target.checked) publishState.selected.add(listing.id); + else publishState.selected.delete(listing.id); + } + renderPublishList(); + updatePublishToolbar(); + }); + + const platformSelect = document.getElementById('publish-filter-platform'); + platformSelect.innerHTML = '' + + state.platforms.map(platform => ``).join(''); + + document.querySelectorAll('[data-publish-view]').forEach(button => { + button.addEventListener('click', () => setPublishView(button.dataset.publishView)); + }); + document.querySelectorAll('[data-status-target]').forEach(button => { + button.addEventListener('click', () => setPublishView(button.dataset.statusTarget)); + }); + + document.getElementById('publish-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-status-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-batch-copy')?.addEventListener('click', batchSmartCopy); + document.getElementById('publish-batch-etsy')?.addEventListener('click', batchEtsyPublish); + document.getElementById('publish-batch-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-add-selected-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-batch-schedule')?.addEventListener('click', openBatchSchedule); + document.getElementById('publish-download-photos')?.addEventListener('click', batchDownloadPhotos); + document.getElementById('publish-ebay-queue-btn')?.addEventListener('click', openEbayQueue); + document.getElementById('publish-schedule-overview')?.addEventListener('click', openScheduleOverview); + document.getElementById('publish-ebay-queue-process')?.addEventListener('click', processEbayQueue); + document.getElementById('publish-ebay-queue-retry')?.addEventListener('click', retryEbayQueue); + + document.getElementById('schedule-modal-close')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-cancel')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-modal')?.addEventListener('click', event => { + if (event.target === event.currentTarget) closePublishSchedule(); + }); +} + +async function publishSafeGet(url, fallback, attempts = 1) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt++) { + try { return await api('GET', url); } + catch (error) { + lastError = error; + if (attempt < attempts - 1) await new Promise(resolve => setTimeout(resolve, 220 * (attempt + 1))); + } + } + if (fallback === PUBLISH_REQUIRED) throw lastError || new Error('Daten konnten nicht geladen werden'); + return fallback; +} + +const PUBLISH_REQUIRED = Symbol('publish-required'); + +async function loadPublishList() { + const search = document.getElementById('publish-search'); + const listContainer = document.getElementById('publish-list'); + if (!search || !listContainer) return; + const renderToken = ++publishState.renderToken; + + const q = search.value.trim(); + const platform = document.getElementById('publish-filter-platform')?.value || ''; + const status = document.getElementById('publish-filter-status')?.value || ''; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + + const refreshButtons = [document.getElementById('publish-refresh'), document.getElementById('publish-status-refresh')]; + refreshButtons.forEach(button => button?.classList.add('is-loading')); + if (!publishState.listings.length) listContainer.innerHTML = '
                                              Listings werden geladen…
                                              '; + + try { + const query = params.toString(); + const [listings, queue, scheduled, workflow, ebayStatus, etsyStatus] = await Promise.all([ + publishSafeGet(`/api/listings${query ? `?${query}` : ''}`, PUBLISH_REQUIRED, 3), + publishSafeGet('/api/ebay/queue', [], 2), + publishSafeGet('/api/schedule', [], 2), + publishSafeGet('/api/dashboard/workflow?limit=4', null, 2), + publishSafeGet('/api/ebay/status', { connected: false, hasCredentials: false }, 2), + publishSafeGet('/api/etsy/status', { connected: false, hasApiKey: false }, 2), + ]); + if (renderToken !== publishState.renderToken) return; + + publishState.allFiltered = Array.isArray(listings) ? listings : []; + publishState.listings = [...publishState.allFiltered]; + publishState.queue = Array.isArray(queue) ? queue : []; + publishState.scheduled = Array.isArray(scheduled) ? scheduled : []; + publishState.workflow = workflow; + publishState.integrations = { ebay: ebayStatus, etsy: etsyStatus }; + + const ids = publishState.listings.map(listing => listing.id); + publishState.publishStatus = ids.length + ? await publishSafeGet(`/api/publish/status?ids=${ids.join(',')}`, {}, 2) + : {}; + if (renderToken !== publishState.renderToken) return; + + sortPublishListings(); + const visibleIds = new Set(publishState.listings.map(listing => listing.id)); + if (!publishState.selectedId || !visibleIds.has(publishState.selectedId)) { + const selectedVisible = [...publishState.selected].find(id => visibleIds.has(id)); + publishState.selectedId = selectedVisible || publishState.listings[0]?.id || null; + } + + renderPublishList(); + renderPublishWorkspace(); + renderPublishStatusRail(); + updatePublishToolbar(); + } catch (error) { + if (renderToken !== publishState.renderToken) return; + toast(`Publish-Center konnte nicht geladen werden: ${error.message}`); + listContainer.innerHTML = `
                                              Listings konnten nicht geladen werden${escapeHtml(error.message)}
                                              `; + listContainer.querySelector('[data-publish-retry]')?.addEventListener('click', loadPublishList); + } finally { + if (renderToken === publishState.renderToken) refreshButtons.forEach(button => button?.classList.remove('is-loading')); + } +} + +function sortPublishListings() { + const sort = document.getElementById('publish-sort')?.value || 'newest'; + const byDate = value => new Date(value || 0).getTime() || 0; + publishState.listings.sort((a, b) => { + if (sort === 'oldest') return byDate(a.created_at) - byDate(b.created_at); + if (sort === 'price-desc') return (Number(b.price) || 0) - (Number(a.price) || 0); + if (sort === 'price-asc') return (Number(a.price) || 0) - (Number(b.price) || 0); + if (sort === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'de'); + return byDate(b.created_at) - byDate(a.created_at); + }); +} + +function publishLatestStatus(listing) { + const statuses = publishState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return { label: 'Vorbereitet', status: 'prepared', platform: listing.platform }; + const priority = { failed: 0, uploading: 1, pending: 2, copied: 3, published: 4 }; + entries.sort((a, b) => (priority[a[1]] ?? 9) - (priority[b[1]] ?? 9)); + const [platform, status] = entries[0]; + const labels = { failed: 'Fehlgeschlagen', uploading: 'In Bearbeitung', pending: 'Ausstehend', copied: 'Kopiert', published: 'Live' }; + return { label: labels[status] || status, status, platform }; +} + +function renderPublishList() { + const container = document.getElementById('publish-list'); + if (!container) return; + document.getElementById('publish-list-count').textContent = publishState.listings.length; + + if (!publishState.listings.length) { + container.innerHTML = '
                                              Keine Listings gefundenÄndere Suche oder Filter.
                                              '; + updatePublishToolbar(); + return; + } + + container.innerHTML = publishState.listings.map(listing => { + const photo = listing.photos?.[0]; + const status = publishLatestStatus(listing); + const selected = listing.id === publishState.selectedId; + const checked = publishState.selected.has(listing.id); + return `
                                              + +
                                              ${photo ? `` : 'Kein Bild'}
                                              +
                                              + ${escapeHtml(listing.title || 'Ohne Titel')} + ${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} +
                                              ${escapeHtml(platformLabel(listing.platform))}${formatCurrency(listing.price) || 'Preis fehlt'}
                                              +
                                              + ${escapeHtml(status.label)} +
                                              `; + }).join(''); + + container.querySelectorAll('[data-publish-listing-id]').forEach(card => { + card.addEventListener('click', event => { + if (event.target.closest('input,button,a,label')) return; + publishState.selectedId = Number(card.dataset.publishListingId); + renderPublishList(); + renderPublishWorkspace(); + }); + }); + + container.querySelectorAll('[data-publish-check]').forEach(checkbox => { + checkbox.addEventListener('change', event => { + const id = Number(event.target.dataset.publishCheck); + if (event.target.checked) publishState.selected.add(id); + else publishState.selected.delete(id); + if (!publishState.selectedId) publishState.selectedId = id; + updatePublishToolbar(); + }); + }); +} + +function updatePublishToolbar() { + const count = publishState.selected.size; + const selectedLabel = document.getElementById('publish-selected-count'); + if (selectedLabel) selectedLabel.textContent = `${count} ausgewählt`; + ['publish-batch-copy', 'publish-batch-etsy', 'publish-batch-ebay', 'publish-batch-schedule', 'publish-download-photos', 'publish-add-selected-ebay'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + + const visible = publishState.listings; + const selectedVisible = visible.filter(listing => publishState.selected.has(listing.id)).length; + const selectAll = document.getElementById('publish-select-all'); + if (selectAll) { + selectAll.checked = visible.length > 0 && selectedVisible === visible.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length; + } +} + +function setPublishView(view) { + publishState.view = ['smart', 'direct', 'queue', 'scheduled'].includes(view) ? view : 'smart'; + document.querySelectorAll('[data-publish-view]').forEach(button => { + const active = button.dataset.publishView === publishState.view; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + renderPublishWorkspace(); +} + +function selectedPublishListing() { + return publishState.listings.find(listing => listing.id === publishState.selectedId) + || publishState.allFiltered.find(listing => listing.id === publishState.selectedId) + || null; +} + +function renderPublishWorkspace() { + if (publishState.view === 'queue') { renderQueueWorkspace(); return; } + if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; } + + const listing = selectedPublishListing(); + const workspace = document.getElementById('publish-workspace'); + if (!workspace) return; + if (!listing) { + workspace.innerHTML = `

                                              Listing auswählen

                                              Wähle links ein Listing, um es für die Veröffentlichung vorzubereiten.

                                              `; + return; + } + if (publishState.view === 'direct') renderDirectPublishWorkspace(listing); + else renderSmartCopyWorkspace(listing); +} + +function publishWorkspaceHeader(listing, kicker, stateLabel) { + const photo = listing.photos?.[0]; + return `
                                              +
                                              +
                                              ${photo ? `` : 'Kein Bild'}
                                              +
                                              ${escapeHtml(kicker)}

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

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

                                              +
                                              +
                                              + ${escapeHtml(stateLabel)} + + +
                                              +
                                              `; +} + +function bindPublishWorkspaceNavigation() { + const currentIndex = publishState.listings.findIndex(listing => listing.id === publishState.selectedId); + document.querySelector('[data-publish-previous]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex <= 0 ? publishState.listings.length - 1 : currentIndex - 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); renderPublishWorkspace(); + }); + document.querySelector('[data-publish-next]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex >= publishState.listings.length - 1 ? 0 : currentIndex + 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); renderPublishWorkspace(); + }); +} + +function publishPlatformTarget(platform) { + return ({ + vinted: { name: 'Vinted', url: 'https://www.vinted.de/items/new', direct: false }, + 'ebay-ka': { name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', direct: false }, + 'ebay-de': { name: 'eBay.de', url: 'https://www.ebay.de/sell/create', direct: true }, + etsy: { name: 'Etsy', url: 'https://www.etsy.com/your/shops/me/tools/listings/create', direct: true }, + })[platform] || { name: platformLabel(platform), url: null, direct: false }; +} + +function publishPhotoGrid(photos, limit = 6) { + if (!photos?.length) return '
                                              Keine Fotos hinterlegt
                                              '; + const visible = photos.slice(0, limit); + return `
                                              ${visible.map(photo => ``).join('')}${photos.length > limit ? `+${photos.length - limit} weitere` : ''}
                                              `; +} + +async function renderSmartCopyWorkspace(listing) { + const workspace = document.getElementById('publish-workspace'); + const token = ++publishState.renderToken; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Wird vorbereitet')}
                                              Daten werden vorbereitet …
                                              `; + bindPublishWorkspaceNavigation(); + + try { + const data = await api('POST', '/api/publish/smart-copy', { listing_id: listing.id }); + if (token !== publishState.renderToken || publishState.view !== 'smart') return; + const fields = data.fields || {}; + const target = publishPlatformTarget(listing.platform); + const tags = Array.isArray(fields.tags) ? fields.tags.join(', ') : (fields.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit zum Kopieren')} +
                                              +
                                              +
                                              +
                                              ${String(fields.title || '').length} Zeichen
                                              +
                                              +
                                              +
                                              +
                                              + +
                                              +
                                              +
                                              ${tags ? tags.split(',').filter(Boolean).length : 0}
                                              +
                                              +
                                              +
                                              + ${publishReadonlyMeta('Preis', fields.price ? formatCurrency(fields.price) : 'Nicht gesetzt', 'publish-smart-price')} + ${publishReadonlyMeta('Marke', fields.brand || '—', 'publish-smart-brand')} + ${publishReadonlyMeta('Kategorie', fields.category || '—', 'publish-smart-category')} + ${publishReadonlyMeta('Größe', fields.size || '—', 'publish-smart-size')} + ${publishReadonlyMeta('Zustand', fields.condition || '—', 'publish-smart-condition')} +
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              ${target.url ? `` : ''}
                                              +
                                              `; + + bindPublishWorkspaceNavigation(); + bindSmartCopyActions(listing, data, target, fields, tags); + } catch (error) { + if (token !== publishState.renderToken) return; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}
                                              Smart Copy konnte nicht vorbereitet werden

                                              ${escapeHtml(error.message)}

                                              `; + bindPublishWorkspaceNavigation(); + workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing)); + } +} + +function publishReadonlyMeta(label, value, id) { + return ``; +} + +function bindSmartCopyActions(listing, data, target, fields, tags) { + document.querySelectorAll('[data-copy-publish-field]').forEach(button => { + button.addEventListener('click', () => copyPublishValue(document.getElementById(button.dataset.copyPublishField)?.value || '', button)); + }); + document.querySelector('[data-copy-publish-description]')?.addEventListener('click', event => copyPublishValue(fields.description || '', event.currentTarget)); + document.querySelector('[data-copy-publish-html]')?.addEventListener('click', async event => { + try { + const html = await api('POST', '/api/html-wrap', { listing_id: listing.id, platform: listing.platform }); + copyPublishValue(html.html || fields.description || '', event.currentTarget); + } catch (error) { toast(`HTML konnte nicht erstellt werden: ${error.message}`); } + }); + document.querySelector('[data-copy-publish-all]')?.addEventListener('click', event => copyPublishValue(`${fields.title || ''}\n\n${fields.description || ''}\n\n${tags}`, event.currentTarget)); + document.querySelector('[data-open-publish-platform]')?.addEventListener('click', () => { if (target.url) window.open(target.url, '_blank', 'noopener'); }); + document.querySelector('[data-mark-publish-live]')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + await api('POST', '/api/publish/mark', { listing_id: listing.id, platform: listing.platform, status: 'published' }); + toast('Listing als veröffentlicht markiert'); + await loadPublishList(); + } catch (error) { toast(`Fehler: ${error.message}`); } + finally { button.disabled = false; } + }); + document.querySelector('[data-download-smart-photos]')?.addEventListener('click', () => downloadPublishPhotos(data.photos || [])); + document.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { switchToTab('history'); showDetail(listing.id); }); +} + +function copyPublishValue(value, button) { + navigator.clipboard.writeText(String(value || '')).then(() => { + const old = button?.textContent; + if (button) button.textContent = 'Kopiert'; + toast('In die Zwischenablage kopiert'); + if (button) setTimeout(() => { button.textContent = old; }, 1200); + }).catch(() => toast('Kopieren nicht möglich')); +} + +function publishReadinessItems(listing) { + return [ + { label: 'Titel', ok: Boolean(listing.title?.trim()) }, + { label: 'Beschreibung', ok: Boolean(listing.description?.trim()) }, + { label: 'Preis', ok: Number(listing.price) > 0 }, + { label: 'Bilder', ok: Boolean(listing.photos?.length) }, + { label: 'Kategorie', ok: Boolean(listing.category?.trim()) }, + { label: 'Zustand', ok: Boolean(listing.condition?.trim()) }, + ]; +} + +function publishReadinessScore(listing) { + const items = publishReadinessItems(listing); + return Math.round((items.filter(item => item.ok).length / items.length) * 100); +} + +function renderDirectPublishWorkspace(listing) { + const workspace = document.getElementById('publish-workspace'); + const target = publishPlatformTarget(listing.platform); + const connected = listing.platform === 'ebay-de' + ? Boolean(publishState.integrations.ebay?.connected) + : listing.platform === 'etsy' + ? Boolean(publishState.integrations.etsy?.connected) + : false; + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Direkt veröffentlichen', target.direct ? (connected ? 'Verbindung bereit' : 'Nicht verbunden') : 'Smart Copy erforderlich')} +
                                              +
                                              +
                                              ${String(listing.title || '').length}/80
                                              +
                                              ${String(listing.description || '').length}/5000
                                              +
                                              ${Array.isArray(listing.tags) ? listing.tags.length : 0}
                                              +
                                              + + + + + + +
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              ${listing.platform === 'ebay-de' ? '' : ''}
                                              +
                                              `; + + bindPublishWorkspaceNavigation(); + const title = document.getElementById('publish-direct-title'); + const description = document.getElementById('publish-direct-description'); + title?.addEventListener('input', () => { document.getElementById('publish-direct-title-count').textContent = `${title.value.length}/80`; }); + description?.addEventListener('input', () => { document.getElementById('publish-direct-description-count').textContent = `${description.value.length}/5000`; }); + document.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { switchToTab('history'); showDetail(listing.id); }); + document.querySelector('[data-publish-save-direct]')?.addEventListener('click', () => saveDirectPublishDraft(listing)); + document.querySelector('[data-add-current-ebay]')?.addEventListener('click', async () => { + publishState.selected.add(listing.id); updatePublishToolbar(); await batchEbayAdd(); + }); + document.querySelector('[data-publish-direct]')?.addEventListener('click', async event => { + if (!target.direct) { setPublishView('smart'); return; } + if (!connected) { toast(`${target.name} ist nicht verbunden`); switchToTab('settings'); return; } + const saved = await saveDirectPublishDraft(listing, false); + if (!saved) return; + const button = event.currentTarget; + button.disabled = true; button.textContent = 'Wird veröffentlicht …'; + try { + if (listing.platform === 'etsy') await api('POST', '/api/publish/etsy', { listing_id: listing.id }); + else await api('POST', `/api/ebay/publish/${listing.id}`); + toast(`Auf ${target.name} veröffentlicht`); + notify('Vendoo Publish', `${listing.title || listing.sku} ist jetzt auf ${target.name} live.`); + await loadPublishList(); + } catch (error) { toast(`Veröffentlichung fehlgeschlagen: ${error.message}`); } + finally { button.disabled = false; button.textContent = 'Jetzt veröffentlichen'; } + }); +} + +async function saveDirectPublishDraft(listing, showSuccess = true) { + const payload = { + title: document.getElementById('publish-direct-title')?.value.trim() || '', + description: document.getElementById('publish-direct-description')?.value.trim() || '', + tags: (document.getElementById('publish-direct-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean), + price: Number.parseFloat(document.getElementById('publish-direct-price')?.value) || null, + condition: document.getElementById('publish-direct-condition')?.value.trim() || null, + brand: document.getElementById('publish-direct-brand')?.value.trim() || null, + size: document.getElementById('publish-direct-size')?.value.trim() || null, + color: document.getElementById('publish-direct-color')?.value.trim() || null, + category: document.getElementById('publish-direct-category')?.value.trim() || null, + }; + try { + const updated = await api('PUT', `/api/listings/${listing.id}`, payload); + const replace = items => items.map(item => item.id === updated.id ? updated : item); + publishState.listings = replace(publishState.listings); + publishState.allFiltered = replace(publishState.allFiltered); + if (showSuccess) toast('Listing gespeichert'); + return updated; + } catch (error) { + toast(`Speichern fehlgeschlagen: ${error.message}`); + return null; + } +} + +function renderQueueWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const queue = publishState.queue; + const counts = publishQueueCounts(); + workspace.innerHTML = `
                                              eBay Automatisierung

                                              eBay Queue

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

                                              +
                                              ${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '

                                              Queue ist leer

                                              Wähle links Listings aus und füge sie zur eBay Queue hinzu.

                                              '}
                                              +
                                              eBay-Verbindung: ${publishState.integrations.ebay?.connected ? 'Verbunden' : 'Nicht verbunden'}
                                              `; + + workspace.querySelector('[data-queue-process]')?.addEventListener('click', processEbayQueue); + workspace.querySelector('[data-queue-retry]')?.addEventListener('click', retryEbayQueue); + workspace.querySelector('[data-queue-clear]')?.addEventListener('click', clearEbayQueueUI); + workspace.querySelector('[data-queue-add-selected]')?.addEventListener('click', batchEbayAdd); + workspace.querySelectorAll('[data-queue-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/ebay/queue/${button.dataset.queueRemove}`); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishQueueRow(item) { + const labels = { pending: 'Ausstehend', uploading: 'In Bearbeitung', published: 'Veröffentlicht', failed: 'Fehlgeschlagen' }; + return `
                                              +
                                              ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                                              ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                                              +
                                              Status${escapeHtml(labels[item.status] || item.status)}
                                              +
                                              Versuche${Number(item.retries) || 0}
                                              +
                                              ${item.error ? `${escapeHtml(item.error)}` : 'Keine Fehler'}
                                              +
                                              ${item.ebay_url ? `Ansehen` : ''}
                                              +
                                              `; +} + +function renderScheduledWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const items = publishState.scheduled; + workspace.innerHTML = `
                                              Zeitsteuerung

                                              Geplante Veröffentlichungen

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

                                              +
                                              ${items.length ? items.map(item => publishScheduleRow(item)).join('') : '

                                              Nichts geplant

                                              Plane ausgewählte Listings für einen späteren Zeitpunkt.

                                              '}
                                              `; + + workspace.querySelector('[data-schedule-selected]')?.addEventListener('click', openBatchSchedule); + workspace.querySelectorAll('[data-schedule-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/schedule/${button.dataset.scheduleRemove}`); toast('Planung entfernt'); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishScheduleRow(item) { + const statusLabels = { scheduled: 'Geplant', publishing: 'In Bearbeitung', published: 'Veröffentlicht', failed: 'Fehlgeschlagen' }; + const date = new Date(String(item.scheduled_at || '').replace(' ', 'T') + (String(item.scheduled_at || '').includes('Z') ? '' : 'Z')); + const dateText = Number.isNaN(date.getTime()) ? item.scheduled_at : date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); + return `
                                              +
                                              ${escapeHtml(dateText || '—')}${escapeHtml(platformLabel(item.platform))}
                                              +
                                              ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                                              ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                                              + ${escapeHtml(statusLabels[item.status] || item.status)} + ${item.error ? `${escapeHtml(item.error)}` : ''} + ${item.status === 'scheduled' ? `` : ''} +
                                              `; +} + +function publishQueueCounts() { + return publishState.queue.reduce((counts, item) => { + if (item.status === 'pending') counts.pending++; + else if (item.status === 'uploading') counts.processing++; + else if (item.status === 'published') counts.published++; + else if (item.status === 'failed') counts.failed++; + return counts; + }, { pending: 0, processing: 0, published: 0, failed: 0 }); +} + +function renderPublishStatusRail() { + const counts = publishQueueCounts(); + const workflowPublished = publishState.workflow?.stages?.published?.count || 0; + const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length; + const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; }; + setText('publish-status-pending', counts.pending); + setText('publish-status-processing', counts.processing); + setText('publish-status-published', Math.max(counts.published, workflowPublished)); + setText('publish-status-failed', counts.failed); + setText('publish-status-scheduled', publishState.scheduled.length); + setText('publish-direct-count', directEligible); + setText('publish-queue-count', publishState.queue.length); + setText('publish-scheduled-count', publishState.scheduled.length); + setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler warten` : 'Keine Fehler'); + setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen'); + + const activity = publishState.workflow?.activity || []; + const container = document.getElementById('publish-activity'); + if (container) { + container.innerHTML = activity.length ? activity.slice(0, 6).map(item => `
                                              ${activityIcon(item.type)}
                                              ${escapeHtml(item.text || '')}${relativeTime(item.at)}
                                              `).join('') : '
                                              Noch keine Publish-Aktivität
                                              '; + } +} + +async function openSmartCopy(listingId) { + publishState.selectedId = Number(listingId); + setPublishView('smart'); + renderPublishList(); +} + +async function batchSmartCopy() { + const id = [...publishState.selected][0] || publishState.selectedId; + if (!id) return; + openSmartCopy(id); +} + +async function batchEtsyPublish() { + const ids = [...publishState.selected]; + const eligible = publishState.listings.filter(listing => ids.includes(listing.id) && listing.platform === 'etsy'); + if (!eligible.length) { toast('Keine ausgewählten Etsy-Listings'); return; } + let succeeded = 0; + let failed = 0; + for (const listing of eligible) { + try { await api('POST', '/api/publish/etsy', { listing_id: listing.id }); succeeded++; } + catch { failed++; } + } + toast(`Etsy: ${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); +} + +async function batchEbayAdd() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const response = await api('POST', '/api/ebay/queue', { listing_ids: ids }); + toast(`${response.added} Listing${response.added === 1 ? '' : 's'} zur eBay Queue hinzugefügt`); + publishState.view = 'queue'; + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function openEbayQueue() { setPublishView('queue'); } + +async function processEbayQueue() { + if (!publishState.integrations.ebay?.connected) { toast('eBay ist nicht verbunden'); switchToTab('settings'); return; } + const buttons = document.querySelectorAll('[data-queue-process], #publish-ebay-queue-process'); + buttons.forEach(button => { button.disabled = true; }); + try { + const response = await api('POST', '/api/ebay/queue/process'); + const succeeded = response.results?.filter(item => item.status === 'published').length || 0; + const failed = response.results?.filter(item => item.status === 'failed').length || 0; + toast(`${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + notify('Vendoo — eBay Queue', `${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Queue-Fehler: ${error.message}`); } + finally { buttons.forEach(button => { button.disabled = false; }); } +} + +async function retryEbayQueue() { + try { + const response = await api('POST', '/api/ebay/queue/retry'); + toast(`${response.retried} Einträge erneut eingeplant`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function clearEbayQueueUI() { + if (!confirm('eBay Queue wirklich vollständig leeren?')) return; + try { await api('DELETE', '/api/ebay/queue'); toast('eBay Queue geleert'); await loadPublishList(); setPublishView('queue'); } + catch (error) { toast(`Fehler: ${error.message}`); } +} + +let scheduleListingIds = []; + +function openBatchSchedule() { + scheduleListingIds = [...publishState.selected]; + if (!scheduleListingIds.length && publishState.selectedId) scheduleListingIds = [publishState.selectedId]; + if (!scheduleListingIds.length) { toast('Bitte mindestens ein Listing auswählen'); return; } + openPublishScheduleDialog(scheduleListingIds); +} + +function openSingleSchedule(listingId) { + scheduleListingIds = [Number(listingId)]; + openPublishScheduleDialog(scheduleListingIds); +} + +function openPublishScheduleDialog(ids) { + const now = new Date(Date.now() + 30 * 60 * 1000); + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + const input = document.getElementById('schedule-datetime'); + input.value = local; + input.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); + document.getElementById('schedule-modal-title').textContent = ids.length === 1 ? 'Veröffentlichung planen' : `${ids.length} Veröffentlichungen planen`; + const selected = publishState.listings.filter(listing => ids.includes(listing.id)); + document.getElementById('schedule-listing-summary').innerHTML = selected.slice(0, 4).map(listing => `${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}`).join('') + (selected.length > 4 ? `+${selected.length - 4} weitere` : ''); + document.getElementById('schedule-modal').classList.remove('hidden'); + + document.getElementById('schedule-confirm').onclick = async () => { + const dateTime = input.value; + const platform = document.getElementById('schedule-platform').value; + if (!dateTime) { toast('Bitte Datum und Uhrzeit auswählen'); return; } + const utc = new Date(dateTime).toISOString().replace('T', ' ').slice(0, 19); + let succeeded = 0; + let failed = 0; + for (const id of ids) { + try { await api('POST', '/api/schedule', { listing_id: id, platform, scheduled_at: utc }); succeeded++; } + catch { failed++; } + } + toast(`${succeeded} Veröffentlichung${succeeded === 1 ? '' : 'en'} geplant${failed ? `, ${failed} fehlgeschlagen` : ''}`); + closePublishSchedule(); + publishState.view = 'scheduled'; + await loadPublishList(); + setPublishView('scheduled'); + }; +} + +function closePublishSchedule() { + document.getElementById('schedule-modal')?.classList.add('hidden'); +} + +async function openScheduleOverview() { setPublishView('scheduled'); } + +async function batchDownloadPhotos() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const data = await api('POST', '/api/publish/batch-photos', { listing_ids: ids }); + await downloadPublishPhotos(data.photos || []); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function downloadPublishPhotos(photos) { + const unique = [...new Set((photos || []).filter(Boolean))]; + if (!unique.length) { toast('Keine Fotos vorhanden'); return; } + if (unique.length === 1) { + const photo = unique[0]; + const link = document.createElement('a'); + link.href = photoUrl(photo); + link.download = String(photo).split('/').pop() || 'vendoo-foto.jpg'; + document.body.appendChild(link); + link.click(); + link.remove(); + toast('Foto wird gespeichert'); + return; + } + + try { + const response = await fetch('/api/photos/archive', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ photos: unique }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'ZIP konnte nicht erstellt werden'); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `vendoo-fotos-${new Date().toISOString().slice(0, 10)}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + toast(`${unique.length} Fotos wurden als ZIP gepackt`); + } catch (error) { + toast(`Foto-ZIP fehlgeschlagen: ${error.message}`); + } +} + + +// --- Settings --- +async function detectPublicBaseUrl({ apply = true } = {}) { + const input = document.getElementById('setting-public-base-url'); + const status = document.getElementById('public-base-url-status'); + const button = document.getElementById('detect-public-base-url-btn'); + if (button) button.disabled = true; + if (status) status.textContent = 'LAN-Adresse wird erkannt …'; + try { + const info = await api('GET', '/api/network-info'); + const preferred = info.public_base_url || info.preferred_url || ''; + if (apply && input && preferred) input.value = preferred; + const adapter = info.addresses?.[0]; + if (status) { + status.textContent = adapter + ? `Erkannt: ${preferred} · Adapter: ${adapter.name}` + : `Keine LAN-IP erkannt. Aktuelle Adresse: ${preferred || 'localhost'}`; + } + return info; + } catch (err) { + if (status) status.textContent = `LAN-Adresse konnte nicht erkannt werden: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +function updateAiPhotoProviderSettingsUi() { + const provider = document.getElementById('setting-ai-model-photo-provider')?.value || 'auto'; + const openrouterSelect = document.getElementById('setting-ai-model-photo-openrouter-model'); + const refreshBtn = document.getElementById('refresh-openrouter-models-btn'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only'); + const openAiField = document.getElementById('setting-ai-model-photo-openai-model'); + const useOpenRouter = provider !== 'openai'; + const useOpenAi = provider !== 'openrouter'; + if (openrouterSelect) openrouterSelect.disabled = !useOpenRouter; + if (refreshBtn) refreshBtn.disabled = !useOpenRouter; + if (freeOnly) freeOnly.disabled = !useOpenRouter; + if (openAiField) openAiField.disabled = !useOpenAi; +} + +function renderOpenRouterModelPreview(models = [], selectedId = '', freeOnly = true) { + const wrap = document.getElementById('openrouter-models-preview'); + const status = document.getElementById('openrouter-models-status'); + const select = document.getElementById('setting-ai-model-photo-openrouter-model'); + if (!wrap || !status || !select) return; + + const normalized = Array.isArray(models) ? models : []; + const options = ['']; + normalized.forEach(model => { + const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`; + options.push(``); + }); + select.innerHTML = options.join(''); + if (selectedId) select.value = selectedId; + if (!select.value) select.value = ''; + + if (!normalized.length) { + wrap.classList.add('hidden'); + wrap.innerHTML = ''; + status.textContent = freeOnly + ? 'Keine kostenlosen OpenRouter-Bildmodelle gefunden. In Auto nutzt Vendoo dann OpenAI als Fallback.' + : 'Keine OpenRouter-Bildmodelle gefunden.'; + return; + } + + const top = normalized.slice(0, 4); + wrap.classList.remove('hidden'); + wrap.innerHTML = top.map(model => ` +
                                              +
                                              +
                                              + ${escapeHtml(model.name || model.id || 'OpenRouter Modell')} + ${escapeHtml(model.id || '')} +
                                              +
                                              + ${model.is_free ? 'Free' : 'Paid'} + Image +
                                              +
                                              +

                                              ${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}

                                              +
                                              + `).join(''); + status.textContent = `${normalized.length} OpenRouter-Bildmodell(e) verfügbar${freeOnly ? ' (Free gefiltert)' : ''}.`; +} + +async function loadOpenRouterImageModels(selectedId = '') { + const status = document.getElementById('openrouter-models-status'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only')?.checked !== false; + if (status) status.textContent = 'OpenRouter-Bildmodelle werden geladen ...'; + try { + const result = await api('GET', `/api/ai-model-photos/providers/openrouter/models?free_only=${freeOnly ? '1' : '0'}`); + state.openRouterImageModels = result.models || []; + renderOpenRouterModelPreview(state.openRouterImageModels, selectedId, !!result.free_only); + if (freeOnly && state.openRouterImageModels.length === 0) { + try { + const diagnostics = await api('GET', '/api/ai-model-photos/providers/openrouter/diagnostics'); + if (status) { + status.textContent = diagnostics.free_image_model_count > 0 + ? `${diagnostics.free_image_model_count} Free-Bildmodell(e) erkannt, aber aktuell nicht nutzbar.` + : `Aktuell 0 kostenlose Bildmodelle bei OpenRouter; ${diagnostics.image_model_count || 0} kostenpflichtige Bildmodelle erkannt. Auto nutzt OpenAI als Fallback, sofern ein OpenAI-Key hinterlegt ist.`; + } + } catch {} + } + } catch (err) { + state.openRouterImageModels = []; + renderOpenRouterModelPreview([], selectedId, freeOnly); + if (status) status.textContent = `OpenRouter-Modelle konnten nicht geladen werden: ${err.message}`; + } +} + +function populateSettingsForm(settings, platforms, providers) { + const ps = document.getElementById('setting-default-platform'); + const fs = document.getElementById('filter-platform'); + const as = document.getElementById('setting-default-ai'); + + ps.innerHTML = ''; fs.innerHTML = ''; + for (const p of platforms) { + ps.innerHTML += ``; + fs.innerHTML += ``; + } + as.innerHTML = providers.map(p => ``).join(''); + + ps.value = settings.default_platform || 'vinted'; + as.value = settings.default_ai || 'claude'; + document.getElementById('setting-default-language').value = settings.default_language || 'de'; + document.getElementById('setting-seller-notes').value = settings.seller_notes || ''; + document.getElementById('setting-anthropic-key').placeholder = settings.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = settings.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = settings.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = settings.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ebay-client-id').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + document.getElementById('setting-ebay-sandbox').checked = !!settings.ebay_sandbox; + document.getElementById('setting-ollama-url').value = settings.ollama_url || 'http://localhost:11434'; + document.getElementById('setting-public-base-url').value = settings.public_base_url || ''; + detectPublicBaseUrl({ apply: !settings.public_base_url }); + document.getElementById('setting-ai-model-photo-provider').value = settings.ai_model_photo_provider || 'auto'; + document.getElementById('setting-ai-model-photo-mode').value = settings.ai_model_photo_default_mode || 'model'; + document.getElementById('setting-ai-model-photo-variants').value = String(settings.ai_model_photo_default_variants || '1'); + document.getElementById('setting-ai-model-photo-preset').value = settings.ai_model_photo_default_preset || 'mixed'; + document.getElementById('setting-ai-model-photo-enabled').checked = String(settings.ai_model_photos_enabled || '1') === '1'; + document.getElementById('setting-ai-model-photo-free-only').checked = String(settings.ai_model_photo_openrouter_free_only || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-underwear').checked = String(settings.ai_model_photo_block_underwear || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-kids').checked = String(settings.ai_model_photo_block_kids || '1') === '1'; + document.getElementById('setting-ai-model-photo-openai-model').value = settings.ai_model_photo_openai_model || 'gpt-image-1'; + updateAiPhotoProviderSettingsUi(); + loadOpenRouterImageModels(settings.ai_model_photo_openrouter_model || ''); + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + const etsyBadge = document.getElementById('etsy-status-badge'); + if (settings.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + if (settings.etsy_connected) { + etsyBadge.textContent = 'Verbunden'; + etsyBadge.className = 'etsy-status connected'; + } else { + etsyBadge.textContent = 'Nicht verbunden'; + etsyBadge.className = 'etsy-status disconnected'; + } + } else { + etsyWrap.classList.add('hidden'); + } + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + const ebayBadge = document.getElementById('ebay-status-badge'); + if (settings.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + if (settings.ebay_connected) { + ebayBadge.textContent = settings.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebayBadge.className = 'etsy-status connected'; + } else { + ebayBadge.textContent = 'Nicht verbunden'; + ebayBadge.className = 'etsy-status disconnected'; + } + } else { + ebayWrap.classList.add('hidden'); + } +} + +function setupSettings() { + document.getElementById('detect-public-base-url-btn')?.addEventListener('click', () => detectPublicBaseUrl({ apply: true })); + document.getElementById('setting-ai-model-photo-provider')?.addEventListener('change', updateAiPhotoProviderSettingsUi); + document.getElementById('setting-ai-model-photo-free-only')?.addEventListener('change', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + document.getElementById('refresh-openrouter-models-btn')?.addEventListener('click', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + + document.getElementById('save-settings-btn').addEventListener('click', async () => { + try { + const settings = { + default_platform: document.getElementById('setting-default-platform').value, + default_ai: document.getElementById('setting-default-ai').value, + default_language: document.getElementById('setting-default-language').value, + seller_notes: document.getElementById('setting-seller-notes').value, + ollama_url: document.getElementById('setting-ollama-url').value, + public_base_url: document.getElementById('setting-public-base-url').value.trim(), + ai_model_photos_enabled: document.getElementById('setting-ai-model-photo-enabled').checked ? '1' : '0', + ai_model_photo_provider: document.getElementById('setting-ai-model-photo-provider').value, + ai_model_photo_default_mode: document.getElementById('setting-ai-model-photo-mode').value, + ai_model_photo_default_variants: document.getElementById('setting-ai-model-photo-variants').value, + ai_model_photo_default_preset: document.getElementById('setting-ai-model-photo-preset').value, + ai_model_photo_openrouter_free_only: document.getElementById('setting-ai-model-photo-free-only').checked ? '1' : '0', + ai_model_photo_block_underwear: document.getElementById('setting-ai-model-photo-block-underwear').checked ? '1' : '0', + ai_model_photo_block_kids: document.getElementById('setting-ai-model-photo-block-kids').checked ? '1' : '0', + ai_model_photo_openrouter_model: document.getElementById('setting-ai-model-photo-openrouter-model').value, + ai_model_photo_openai_model: document.getElementById('setting-ai-model-photo-openai-model').value.trim() || 'gpt-image-1', + }; + const ak = document.getElementById('setting-anthropic-key').value; + const ok = document.getElementById('setting-openai-key').value; + const ork = document.getElementById('setting-openrouter-key').value; + const ek = document.getElementById('setting-etsy-key').value; + const ebayId = document.getElementById('setting-ebay-client-id').value; + const ebaySec = document.getElementById('setting-ebay-client-secret').value; + const ebayRu = document.getElementById('setting-ebay-ru-name').value; + const ebaySandbox = document.getElementById('setting-ebay-sandbox').checked; + if (ak) settings.anthropic_key = ak; + if (ok) settings.openai_key = ok; + if (ork) settings.openrouter_key = ork; + if (ek) settings.etsy_key = ek; + if (ebayId) settings.ebay_client_id = ebayId; + if (ebaySec) settings.ebay_client_secret = ebaySec; + if (ebayRu) settings.ebay_ru_name = ebayRu; + settings.ebay_sandbox = ebaySandbox ? 'true' : 'false'; + + const saved = await api('PUT', '/api/settings', settings); + state.platform = settings.default_platform; + state.aiProvider = settings.default_ai; + state.language = settings.default_language; + renderPlatformButtons(); + renderProviderButtons(); + document.getElementById('language-select').value = state.language; + + document.getElementById('setting-anthropic-key').value = ''; + document.getElementById('setting-openai-key').value = ''; + document.getElementById('setting-openrouter-key').value = ''; + document.getElementById('setting-etsy-key').value = ''; + document.getElementById('setting-ebay-client-id').value = ''; + document.getElementById('setting-ebay-client-secret').value = ''; + document.getElementById('setting-anthropic-key').placeholder = saved.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = saved.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = saved.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = saved.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ebay-client-id').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + if (saved.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + const badge = document.getElementById('etsy-status-badge'); + if (saved.etsy_connected) { badge.textContent = 'Verbunden'; badge.className = 'etsy-status connected'; } + else { badge.textContent = 'Nicht verbunden'; badge.className = 'etsy-status disconnected'; } + } + + updateAiPhotoProviderSettingsUi(); + if (saved.public_base_url !== undefined) document.getElementById('setting-public-base-url').value = saved.public_base_url || ''; + detectPublicBaseUrl({ apply: false }); + loadOpenRouterImageModels(saved.ai_model_photo_openrouter_model || document.getElementById('setting-ai-model-photo-openrouter-model').value || ''); + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + if (saved.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + const ebBadge = document.getElementById('ebay-status-badge'); + if (saved.ebay_connected) { + ebBadge.textContent = saved.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebBadge.className = 'etsy-status connected'; + } else { + ebBadge.textContent = 'Nicht verbunden'; + ebBadge.className = 'etsy-status disconnected'; + } + } + toast('Einstellungen gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('etsy-connect-btn').addEventListener('click', () => { + window.open('/auth/etsy', 'etsy-auth', 'width=600,height=700'); + }); + document.getElementById('ebay-connect-btn').addEventListener('click', () => { + window.open('/auth/ebay', 'ebay-auth', 'width=600,height=700'); + }); + window.addEventListener('message', async (e) => { + if (e.data === 'etsy-connected') { + const badge = document.getElementById('etsy-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('Etsy erfolgreich verbunden!'); + } + if (e.data === 'ebay-connected') { + const badge = document.getElementById('ebay-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('eBay erfolgreich verbunden!'); + } + }); + + document.querySelectorAll('.toggle-visibility').forEach(btn => { + btn.addEventListener('click', () => { + const inp = btn.previousElementSibling; + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + btn.textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + }); +} + +// --- Category Picker --- +const catCache = {}; + +async function loadCategories(platformId) { + if (catCache[platformId]) return catCache[platformId]; + try { + const data = await api('GET', `/api/categories/${platformId}`); + catCache[platformId] = data; + return data; + } catch { return { tree: [], flat: [] }; } +} + +function createCategoryPicker(container, platformId, initialValue) { + const wrap = typeof container === 'string' ? document.getElementById(container) : container; + if (!wrap) return { getValue: () => null, setValue: () => {} }; + + let selected = null; + let dropdownOpen = false; + + wrap.innerHTML = ` +
                                              + +
                                              + +
                                              `; + + const input = wrap.querySelector('.cat-search'); + const dropdown = wrap.querySelector('.cat-dropdown'); + const selectedDisplay = wrap.querySelector('.cat-selected-display'); + + async function doSearch(q) { + const data = await loadCategories(platformId); + if (!q || q.length < 2) { + renderTree(data.tree); + return; + } + try { + const results = await api('GET', `/api/categories/${platformId}/match?q=${encodeURIComponent(q)}`); + renderResults(results); + } catch { renderResults([]); } + } + + function renderResults(results) { + if (!results.length) { + dropdown.innerHTML = '
                                              Keine Kategorien gefunden
                                              '; + openDropdown(); + return; + } + dropdown.innerHTML = results.map(r => ` +
                                              +
                                              ${escapeHtml(r.name)}
                                              +
                                              ${escapeHtml(r.pathStr)}
                                              +
                                              `).join(''); + dropdown.querySelectorAll('.cat-result-item').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function renderTree(tree) { + if (!tree || !tree.length) { + dropdown.innerHTML = '
                                              Keine Kategorien verfügbar
                                              '; + openDropdown(); + return; + } + dropdown.innerHTML = tree.map(group => { + const children = (group.children || []).map(sub => { + if (sub.children) { + const leaves = sub.children.map(leaf => + `
                                              ${escapeHtml(leaf.name)}
                                              ` + ).join(''); + return `
                                              +
                                              ${escapeHtml(sub.name)}
                                              +
                                              ${leaves}
                                              +
                                              `; + } + return `
                                              ${escapeHtml(sub.name)}
                                              `; + }).join(''); + return `
                                              +
                                              ${escapeHtml(group.name)}
                                              + +
                                              `; + }).join(''); + + dropdown.querySelectorAll('.cat-group-hd').forEach(hd => { + hd.addEventListener('click', () => { + const body = hd.nextElementSibling; + const expanded = hd.dataset.expanded === 'true'; + hd.dataset.expanded = expanded ? 'false' : 'true'; + body.style.display = expanded ? 'none' : 'block'; + hd.classList.toggle('expanded', !expanded); + }); + }); + + dropdown.querySelectorAll('.cat-leaf').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function selectCategory(path, name) { + selected = path; + input.value = ''; + input.style.display = 'none'; + selectedDisplay.style.display = 'flex'; + selectedDisplay.innerHTML = ` + ${escapeHtml(path)} + `; + selectedDisplay.querySelector('.cat-sel-clear').addEventListener('click', clearSelection); + closeDropdown(); + } + + function clearSelection() { + selected = null; + input.style.display = ''; + selectedDisplay.style.display = 'none'; + input.value = ''; + input.focus(); + } + + function openDropdown() { dropdown.style.display = 'block'; dropdownOpen = true; } + function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; } + + input.addEventListener('focus', () => doSearch(input.value)); + input.addEventListener('input', debounce(() => doSearch(input.value), 200)); + + document.addEventListener('click', e => { + if (dropdownOpen && !wrap.contains(e.target)) closeDropdown(); + }); + + if (initialValue) { + selectCategory(initialValue, initialValue.split(' > ').pop()); + } + + return { + getValue: () => selected, + setValue: (val) => { + if (val) selectCategory(val, val.split(' > ').pop()); + else clearSelection(); + }, + }; +} + +let metaCategoryPicker = null; + +function addQuillTooltips() { + const tooltips = { + 'ql-bold': 'Fett', 'ql-italic': 'Kursiv', 'ql-underline': 'Unterstrichen', + 'ql-strike': 'Durchgestrichen', 'ql-blockquote': 'Zitat', 'ql-link': 'Link einfügen', + 'ql-clean': 'Formatierung entfernen', 'ql-list[value="ordered"]': 'Nummerierte Liste', + 'ql-list[value="bullet"]': 'Aufzählung', 'ql-color': 'Textfarbe', + 'ql-background': 'Hintergrundfarbe', 'ql-align': 'Ausrichtung', 'ql-header': 'Überschrift', + }; + for (const [sel, tip] of Object.entries(tooltips)) { + document.querySelectorAll(`.ql-${sel}, .${sel}, button.${sel.split('[')[0]}`).forEach(el => { el.title = tip; }); + } + document.querySelectorAll('.ql-bold').forEach(el => { el.title = 'Fett'; }); + document.querySelectorAll('.ql-italic').forEach(el => { el.title = 'Kursiv'; }); + document.querySelectorAll('.ql-underline').forEach(el => { el.title = 'Unterstrichen'; }); + document.querySelectorAll('.ql-strike').forEach(el => { el.title = 'Durchgestrichen'; }); + document.querySelectorAll('.ql-blockquote').forEach(el => { el.title = 'Zitat'; }); + document.querySelectorAll('.ql-link').forEach(el => { el.title = 'Link einfügen'; }); + document.querySelectorAll('.ql-clean').forEach(el => { el.title = 'Formatierung entfernen'; }); + document.querySelectorAll('.ql-list[value="ordered"]').forEach(el => { el.title = 'Nummerierte Liste'; }); + document.querySelectorAll('.ql-list[value="bullet"]').forEach(el => { el.title = 'Aufzählung'; }); + document.querySelectorAll('.ql-color .ql-picker-label').forEach(el => { el.title = 'Textfarbe'; }); + document.querySelectorAll('.ql-background .ql-picker-label').forEach(el => { el.title = 'Hintergrundfarbe'; }); + document.querySelectorAll('.ql-align .ql-picker-label').forEach(el => { el.title = 'Ausrichtung'; }); + document.querySelectorAll('.ql-header .ql-picker-label').forEach(el => { el.title = 'Überschrift'; }); +} + +function generateQRDataUrl(text, size) { + if (typeof QRious === 'undefined') return null; + try { + const qr = new QRious({ value: text, size: size || 120, level: 'M' }); + return qr.toDataURL(); + } catch { return null; } +} + +let printStudioState = { + mode: 'labels', + listings: [], + listing: null, + copies: 1, + format: '90x50', + showPrice: true, +}; + +function ensurePrintStudio() { + let studio = document.getElementById('print-studio'); + if (studio) return studio; + + studio = document.createElement('div'); + studio.id = 'print-studio'; + studio.className = 'print-studio hidden'; + studio.innerHTML = ` + + + `; + document.body.appendChild(studio); + + studio.querySelectorAll('[data-print-close]').forEach(button => { + button.addEventListener('click', closePrintStudio); + }); + studio.querySelector('#print-format-select').addEventListener('change', event => { + printStudioState.format = event.target.value; + renderPrintStudio(); + }); + studio.querySelector('#print-copies-input').addEventListener('input', event => { + printStudioState.copies = Math.max(1, Math.min(20, Number(event.target.value) || 1)); + renderPrintStudio(); + }); + studio.querySelector('#print-show-price').addEventListener('change', event => { + printStudioState.showPrice = event.target.checked; + renderPrintStudio(); + }); + studio.querySelector('#print-now-btn').addEventListener('click', () => { + document.body.classList.add('print-studio-active'); + window.print(); + }); + window.addEventListener('afterprint', () => document.body.classList.remove('print-studio-active')); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !studio.classList.contains('hidden')) closePrintStudio(); + }); + return studio; +} + +function closePrintStudio() { + const studio = document.getElementById('print-studio'); + if (!studio) return; + studio.classList.add('hidden'); + document.body.classList.remove('print-studio-open', 'print-studio-active'); +} + +function openPrintStudio(mode, payload) { + printStudioState = { + mode, + listings: payload.listings || [], + listing: payload.listing || null, + copies: 1, + format: '90x50', + showPrice: true, + }; + const studio = ensurePrintStudio(); + studio.classList.remove('hidden'); + document.body.classList.add('print-studio-open'); + studio.querySelector('#print-format-select').value = printStudioState.format; + studio.querySelector('#print-copies-input').value = '1'; + studio.querySelector('#print-show-price').checked = true; + renderPrintStudio(); +} + +function printLabels(listings) { + if (!Array.isArray(listings) || !listings.length) return; + openPrintStudio('labels', { listings }); +} + +function printDatasheet(listing) { + if (!listing) return; + openPrintStudio('datasheet', { listing }); +} + +function getPrintPlatformName(platform) { + return state.platforms.find(item => item.id === platform)?.name || platformLabel(platform); +} + +function buildPrintLabel(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const title = (listing.title || 'Ohne Titel').substring(0, 60); + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 170); + const price = listing.price ? formatCurrency(listing.price) : ''; + const platform = getPrintPlatformName(listing.platform); + return ` + + `; +} + +function buildPrintDatasheet(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 190); + const photos = (listing.photos || []).slice(0, 4); + const dateValue = listing.created_at ? new Date(`${listing.created_at}Z`) : new Date(); + const date = Number.isNaN(dateValue.getTime()) ? '' : dateValue.toLocaleDateString('de-DE'); + const meta = [ + ['Marke', listing.brand], + ['Kategorie', listing.category], + ['Größe', listing.size], + ['Farbe', listing.color], + ['Zustand', listing.condition], + ['Preis', listing.price ? formatCurrency(listing.price) : ''], + ['Plattform', getPrintPlatformName(listing.platform)], + ['Lagerort', listing.storage_location], + ['Erstellt', date], + ].filter(([, value]) => value); + + return ` + + `; +} + +function chunkPrintItems(items, size) { + const chunks = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +function renderPrintStudio() { + const studio = ensurePrintStudio(); + const pages = studio.querySelector('#print-preview-pages'); + const summary = studio.querySelector('#print-mode-summary'); + const count = studio.querySelector('#print-preview-count'); + const formatField = studio.querySelector('#print-format-field'); + const copiesField = studio.querySelector('#print-copies-field'); + const priceField = studio.querySelector('#print-price-field'); + + const labelsMode = printStudioState.mode === 'labels'; + formatField.classList.toggle('hidden', !labelsMode); + copiesField.classList.toggle('hidden', !labelsMode); + priceField.classList.toggle('hidden', !labelsMode); + + if (!labelsMode) { + const listing = printStudioState.listing; + summary.innerHTML = `
                                              Datenblatt${escapeHtml(listing?.sku || listing?.title || 'Artikel')}
                                              `; + count.textContent = '1 Seite'; + pages.innerHTML = ``; + return; + } + + const expanded = []; + printStudioState.listings.forEach(listing => { + for (let copy = 0; copy < printStudioState.copies; copy += 1) expanded.push(listing); + }); + const compact = printStudioState.format === '62x29'; + const pageSize = compact ? 27 : 10; + const pageChunks = chunkPrintItems(expanded, pageSize); + summary.innerHTML = `
                                              Etiketten${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten
                                              `; + count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`; + pages.innerHTML = pageChunks.map(chunk => ` + + `).join(''); +} + +// --- Trash / Papierkorb --- +function setupTrash() { + document.getElementById('empty-trash-btn').addEventListener('click', async () => { + if (!confirm('Papierkorb endgültig leeren? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', '/api/trash'); + toast('Papierkorb geleert'); + loadTrash(); + updateTrashBadge(); + }); +} + +async function loadTrash() { + try { + const listings = await api('GET', '/api/trash'); + const c = document.getElementById('trash-list'); + if (!listings.length) { + c.innerHTML = '
                                              Papierkorb ist leer
                                              '; + return; + } + + c.innerHTML = listings.map(l => { + const photo = l.photos?.[0] ? `` : '
                                              '; + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const deletedDate = l.deleted_at ? new Date(l.deleted_at + 'Z').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; + const priceStr = l.price ? `${l.price}€` : ''; + + return `
                                              + ${photo} +
                                              +
                                              ${escapeHtml(l.title || 'Ohne Titel')}
                                              +
                                              ${pName} · ${priceStr} · Gelöscht: ${deletedDate}
                                              +
                                              +
                                              + + +
                                              +
                                              `; + }).join(''); + + c.querySelectorAll('.restore-btn').forEach(btn => { + btn.addEventListener('click', async () => { + await api('POST', `/api/trash/${btn.dataset.id}/restore`); + toast('Listing wiederhergestellt'); + loadTrash(); + updateTrashBadge(); + }); + }); + + c.querySelectorAll('.perm-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Listing endgültig löschen? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', `/api/trash/${btn.dataset.id}`); + toast('Endgültig gelöscht'); + loadTrash(); + updateTrashBadge(); + }); + }); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function updateTrashBadge() { + try { + const data = await api('GET', '/api/trash/count'); + const badge = document.getElementById('trash-badge'); + if (data.count > 0) { + badge.textContent = data.count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } catch {} +} + +// --- Utils --- +function toast(msg) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2500); +} + +function debounce(fn, ms) { + let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; +} + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; +} + +// --- Dark Mode --- +function setupDarkMode() { + const saved = localStorage.getItem('vendoo-theme'); + if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.setAttribute('data-theme', 'dark'); + } + updateThemeIcon(); + + document.getElementById('theme-toggle').addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + document.documentElement.setAttribute('data-theme', isDark ? 'light' : 'dark'); + localStorage.setItem('vendoo-theme', isDark ? 'light' : 'dark'); + updateThemeIcon(); + }); +} + +function updateThemeIcon() { + const btn = document.getElementById('theme-toggle'); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + btn.title = isDark ? 'Light Mode' : 'Dark Mode'; + btn.innerHTML = isDark + ? '' + : ''; +} + +// --- Dashboard & Studio Flow --- +const STUDIO_STAGES = [ + { key: 'ready', label: 'Bereit', state: 'Prüfbereit', tone: 'success' }, + { key: 'scheduled', label: 'Geplant', state: 'Geplant', tone: 'warning' }, + { key: 'published', label: 'Veröffentlicht', state: 'Live', tone: 'success' }, + { key: 'sold', label: 'Verkauft', state: 'Verkauft', tone: 'success' }, +]; + +let studioWorkflowData = null; +let selectedStudioListingId = null; +let todayFilter = 'all'; +let dashboardAnalyticsData = null; +let dashboardResizeObserver = null; +let dashboardResizeTimer = null; +let dashboardLastChartWidth = 0; + +function formatCurrency(value) { + if (value === null || value === undefined || value === '') return ''; + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value) || 0); +} + +function platformLabel(platform) { + return ({ vinted: 'Vinted', 'ebay-ka': 'Kleinanzeigen', 'ebay-de': 'eBay', etsy: 'Etsy', klekt: 'KLEKT' })[platform] || platform || '—'; +} + +function photoUrl(photo) { + if (!photo) return ''; + return `/uploads/${String(photo).split('/').map(encodeURIComponent).join('/')}`; +} + +function relativeTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const diff = Math.max(0, Date.now() - date.getTime()); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'Gerade eben'; + if (minutes < 60) return `vor ${minutes} Min.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `vor ${hours} Std.`; + const days = Math.floor(hours / 24); + if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`; + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function activityGlyph(type) { + return ({ sold: '✓', published: '↗', failed: '!', scheduled: '◷', copied: '⧉', pending: '·', created: '+' })[type] || '·'; +} + +function setupAnalyticsDashboard() { + document.getElementById('dashboard-period')?.addEventListener('change', loadAnalyticsDashboard); + document.getElementById('dashboard-refresh')?.addEventListener('click', loadAnalyticsDashboard); + + const dashboardPage = document.getElementById('dashboard'); + const redrawDashboard = () => { + if (!dashboardAnalyticsData || !dashboardPage?.classList.contains('active')) return; + clearTimeout(dashboardResizeTimer); + dashboardResizeTimer = setTimeout(() => { + const width = document.getElementById('dashboard-line-chart')?.clientWidth || 0; + if (Math.abs(width - dashboardLastChartWidth) < 12) return; + dashboardLastChartWidth = width; + renderDashboardLineChart(dashboardAnalyticsData.timeline || []); + renderDashboardBars(dashboardAnalyticsData.platformRevenue || []); + renderDashboardCategories(dashboardAnalyticsData.categoryRevenue || []); + }, 90); + }; + if ('ResizeObserver' in window && dashboardPage) { + dashboardResizeObserver?.disconnect(); + dashboardResizeObserver = new ResizeObserver(redrawDashboard); + dashboardResizeObserver.observe(dashboardPage); + } else { + window.addEventListener('resize', redrawDashboard, { passive: true }); + } + + document.querySelectorAll('[data-dashboard-action]').forEach(button => button.addEventListener('click', async () => { + const action = button.dataset.dashboardAction; + if (action === 'new') return switchToTab('generator'); + if (action === 'qr') { + switchToTab('generator'); + setTimeout(() => document.getElementById('generator-mobile-upload')?.click(), 160); + return; + } + if (action === 'publish') return switchToTab('publish'); + if (action === 'template') return switchToTab('templates'); + if (action === 'photos') { + try { + const listings = await api('GET', '/api/listings'); + const photos = listings.flatMap(item => item.photos || []).slice(0, 60); + await downloadPublishPhotos(photos); + } catch (error) { toast(`Fotos konnten nicht geladen werden: ${error.message}`); } + } + })); +} + +function setupTodayFlow() { + document.querySelectorAll('[data-today-filter]').forEach(button => button.addEventListener('click', () => { + todayFilter = button.dataset.todayFilter || 'all'; + document.querySelectorAll('.today-filter-tabs [data-today-filter]').forEach(tab => tab.classList.toggle('active', tab.dataset.todayFilter === todayFilter)); + renderTodayReady(studioWorkflowData); + if (todayFilter === 'attention') document.querySelector('.attention-panel')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + })); + document.getElementById('today-sort')?.addEventListener('change', () => renderTodayReady(studioWorkflowData)); +} + +function dashboardSetText(id, value) { + const element = document.getElementById(id); + if (element) element.textContent = value; +} + +function renderSparkline(id, values, tone = 'brand') { + const svg = document.getElementById(id); + if (!svg) return; + const raw = (values || []).map(Number).filter(Number.isFinite); + const nums = raw.length > 1 ? raw : [raw[0] || 0, raw[0] || 0]; + const max = Math.max(...nums, 1); + const min = Math.min(...nums, 0); + const range = Math.max(1, max - min); + const points = nums.map((value, index) => `${(index / Math.max(1, nums.length - 1)) * 96 + 2},${24 - ((value - min) / range) * 18}`).join(' '); + const last = points.split(' ').at(-1)?.split(',') || ['98', '14']; + svg.setAttribute('viewBox', '0 0 100 28'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.innerHTML = ``; + svg.dataset.tone = tone; +} + +function renderDashboardLineChart(timeline) { + const container = document.getElementById('dashboard-line-chart'); + if (!container) return; + const values = timeline.map(item => Number(item.revenue) || 0); + const max = Math.max(...values, 1); + const measuredWidth = Math.round(container.getBoundingClientRect().width || container.clientWidth || 720); + const width = Math.max(320, measuredWidth); + const height = width < 520 ? 220 : 250; + const left = width < 520 ? 38 : 48; + const right = 14; + const top = 16; + const bottom = 34; + const chartW = width - left - right; + const chartH = height - top - bottom; + const points = values.map((value, index) => { + const x = left + (index / Math.max(1, values.length - 1)) * chartW; + const y = top + chartH - (value / max) * chartH; + return { x, y, value, item: timeline[index] }; + }); + const line = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); + const area = `${left},${top + chartH} ${line} ${left + chartW},${top + chartH}`; + const yGrid = [0, .25, .5, .75, 1].map(part => { + const y = top + chartH - part * chartH; + return `${Math.round(max * part).toLocaleString('de-DE')} €`; + }).join(''); + const step = Math.max(1, Math.floor(timeline.length / 5)); + const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1 + ? `${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}` : '').join(''); + container.innerHTML = `${yGrid}${labels}${points.map(point => `${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}`).join('')}`; + dashboardLastChartWidth = measuredWidth; +} + +function renderDashboardBars(items) { + const container = document.getElementById('dashboard-platform-chart'); + if (!container) return; + const data = items.length ? items.slice(0, 5) : [{ platform: 'Keine Verkäufe', revenue: 0 }]; + const max = Math.max(...data.map(item => Number(item.revenue) || 0), 1); + container.innerHTML = data.map(item => `
                                              ${escapeHtml(formatCurrency(item.revenue))}
                                              ${escapeHtml(platformLabel(item.platform))}
                                              `).join(''); +} + +function renderDashboardCategories(items) { + const container = document.getElementById('dashboard-category-chart'); + if (!container) return; + const data = items.length ? items : [{ category: 'Keine Verkäufe', revenue: 1 }]; + const total = data.reduce((sum, item) => sum + (Number(item.revenue) || 0), 0) || 1; + let cursor = 0; + const colors = ['#49a66f', '#3b82dc', '#8054b3', '#e87334', '#e4ab2f', '#b9b6b0']; + const stops = data.map((item, index) => { + const start = cursor; + cursor += ((Number(item.revenue) || 0) / total) * 100; + return `${colors[index % colors.length]} ${start}% ${cursor}%`; + }).join(','); + container.innerHTML = `
                                              ${escapeHtml(formatCurrency(total))}Gesamt
                                              ${data.map((item, index) => `
                                              ${escapeHtml(item.category)}${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%
                                              `).join('')}
                                              `; +} + +function renderDashboardFocus(focus) { + const container = document.getElementById('dashboard-focus'); + if (!container) return; + const items = [ + ['ready', focus.ready, 'Listings bereit zum Veröffentlichen', 'history'], + ['price', focus.priceChecks, 'Preisaktualisierungen empfohlen', 'history'], + ['failed', focus.failedPublishes, 'fehlgeschlagene Publish-Versuche', 'publish'], + ['uploads', focus.mobileUploads, 'neue Handy-Foto-Uploads', 'generator'], + ['locations', focus.activeLocations, 'aktive Lagerorte', 'inventory'], + ]; + container.innerHTML = items.map(([tone, count, label, tab]) => ``).join(''); + container.querySelectorAll('[data-focus-tab]').forEach(button => button.addEventListener('click', () => switchToTab(button.dataset.focusTab))); +} + +function renderDashboardTopSellers(items) { + const body = document.getElementById('dashboard-top-sellers'); + if (!body) return; + body.innerHTML = items.length ? items.map(item => `
                                              ${item.photo ? `` : ''}
                                              ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))}
                                              ${escapeHtml(item.sku || '')}${escapeHtml(platformLabel(item.platform))}${escapeHtml(formatCurrency(item.revenue))}${item.sales || 1}Verkauft`).join('') : 'Noch keine Verkäufe vorhanden.'; + body.querySelectorAll('[data-dashboard-listing]').forEach(row => row.addEventListener('click', () => showDetail(Number(row.dataset.dashboardListing)))); +} + +function renderDashboardFeed(id, items, isNotification = false) { + const container = document.getElementById(id); + if (!container) return; + if (!items?.length) { container.innerHTML = '
                                              Keine Einträge
                                              '; return; } + container.innerHTML = items.slice(0, 6).map(item => ``).join(''); + container.querySelectorAll('[data-feed-listing]').forEach(button => button.addEventListener('click', () => { const idValue = Number(button.dataset.feedListing); if (idValue) showDetail(idValue); })); +} + +async function loadAnalyticsDashboard() { + const period = Number(document.getElementById('dashboard-period')?.value || 30); + const refresh = document.getElementById('dashboard-refresh'); + refresh?.classList.add('is-loading'); + try { + const data = await api('GET', `/api/dashboard/analytics?days=${period}`); + dashboardAnalyticsData = data; + dashboardSetText('dash-revenue-today', formatCurrency(data.kpis.revenueToday)); + dashboardSetText('dash-sales-week', String(data.kpis.sales7Days)); + dashboardSetText('dash-active-listings', String(data.kpis.activeListings)); + dashboardSetText('dash-drafts', String(data.kpis.drafts)); + dashboardSetText('dash-inventory-value', formatCurrency(data.kpis.inventoryValue)); + dashboardSetText('dash-publish-rate', `${data.kpis.publishSuccessRate.toLocaleString('de-DE')}%`); + dashboardSetText('dash-period-revenue', formatCurrency(data.timeline.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + dashboardSetText('dash-period-label', `Letzte ${data.periodDays} Tage`); + dashboardSetText('dash-platform-total', formatCurrency(data.platformRevenue.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + const timelineValues = data.timeline.map(item => item.revenue); + renderSparkline('spark-revenue', timelineValues, 'green'); + renderSparkline('spark-sales', data.timeline.map(item => item.sales), 'blue'); + renderSparkline('spark-active', timelineValues.map((_, index) => Math.max(0, data.kpis.activeListings - data.timeline.length + index)), 'purple'); + renderSparkline('spark-drafts', timelineValues.map((_, index) => Math.max(0, data.kpis.drafts + Math.sin(index) * 2)), 'orange'); + renderSparkline('spark-inventory', timelineValues.map((value, index) => data.kpis.inventoryValue * .9 + index * (data.kpis.inventoryValue * .1 / Math.max(1, timelineValues.length))), 'green'); + renderSparkline('spark-publish', timelineValues.map((_, index) => Math.max(0, data.kpis.publishSuccessRate - 4 + index / 3)), 'blue'); + renderDashboardLineChart(data.timeline); + renderDashboardBars(data.platformRevenue); + renderDashboardCategories(data.categoryRevenue); + renderDashboardFocus(data.focus); + renderDashboardTopSellers(data.topSellers); + renderDashboardFeed('dashboard-activity', data.activity); + renderDashboardFeed('dashboard-notifications', data.notifications, true); + dashboardSetText('dashboard-quality-value', `${data.kpis.quality}%`); + dashboardSetText('dashboard-quality-label', data.kpis.quality >= 85 ? 'Sehr hoch' : data.kpis.quality >= 65 ? 'Gut, mit Potenzial' : 'Optimierung empfohlen'); + document.getElementById('dashboard-quality-ring')?.style.setProperty('--confidence', `${data.kpis.quality}%`); + dashboardSetText('dashboard-system-state', data.system.healthy ? 'Alle Systeme sind betriebsbereit' : 'Prüfung erforderlich'); + dashboardSetText('dashboard-system-copy', `${data.system.queuePending} Queue-Einträge ausstehend · ${data.system.scheduled} Veröffentlichungen geplant.`); + } catch (error) { + toast(`Dashboard konnte nicht geladen werden: ${error.message}`); + } finally { refresh?.classList.remove('is-loading'); } +} + +function getStudioItem(id) { + if (!studioWorkflowData?.stages) return null; + for (const stage of Object.values(studioWorkflowData.stages)) { + const found = stage.items?.find(item => item.id === id); + if (found) return found; + } + return null; +} + +function currentTodayItems(data) { + if (!data?.stages) return []; + const key = todayFilter === 'all' ? 'ready' : todayFilter; + if (key === 'attention') return [...(data.stages.captured?.items || []), ...(data.stages.ai_review?.items || [])]; + return data.stages[key]?.items || []; +} + +function renderTodaySummary(data) { + const set = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = String(value || 0); }; + set('today-count-ready', data.stages?.ready?.count); + set('today-count-scheduled', data.stages?.scheduled?.count); + set('today-count-published', data.stages?.published?.count); + set('today-count-sold', data.stages?.sold?.count); + set('today-count-attention', data.attentionCount); + set('today-ready-total', data.stages?.ready?.count); + set('today-scheduled-total', data.stages?.scheduled?.count); +} + +function renderTodayReady(data) { + const container = document.getElementById('today-ready-grid'); + if (!container || !data) return; + const labels = { all: 'Bereit für den nächsten Schritt', ready: 'Bereit für den nächsten Schritt', scheduled: 'Geplante Listings', published: 'Veröffentlichte Listings', sold: 'Verkaufte Listings', attention: 'Listings mit Prüfbedarf' }; + const heading = container.closest('.work-panel')?.querySelector('h3'); + if (heading) heading.textContent = labels[todayFilter] || labels.all; + let items = [...currentTodayItems(data)]; + const sort = document.getElementById('today-sort')?.value || 'priority'; + if (sort === 'newest') items.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt))); + if (sort === 'price') items.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)); + items = items.slice(0, 4); + if (!items.length) { container.innerHTML = '
                                              Für diesen Filter sind keine Listings vorhanden.
                                              '; return; } + container.innerHTML = items.map(item => ``).join(''); + container.querySelectorAll('.today-product-media img').forEach(image => image.addEventListener('error', () => { + image.hidden = true; + const placeholder = document.createElement('span'); + placeholder.className = 'today-image-placeholder'; + placeholder.textContent = 'Bild nicht verfügbar'; + image.parentElement?.prepend(placeholder); + }, { once: true })); + container.querySelectorAll('[data-studio-listing]').forEach(card => card.addEventListener('click', () => selectStudioListing(Number(card.dataset.studioListing)))); +} + +function renderTodayScheduled(data) { + const body = document.getElementById('today-scheduled-list'); + if (!body) return; + const items = data.stages?.scheduled?.items?.slice(0, 4) || []; + body.innerHTML = items.length ? items.map(item => `${escapeHtml(platformLabel(item.platform))}${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}Geplant`).join('') : 'Keine geplanten Veröffentlichungen.'; + body.querySelectorAll('[data-scheduled-id]').forEach(button => button.addEventListener('click', () => selectStudioListing(Number(button.dataset.scheduledId)))); +} + +function renderStudioAttention(data) { + const list = document.getElementById('studio-attention'); + const count = document.getElementById('attention-count'); + if (count) count.textContent = String(data.attentionCount || 0); + if (!list) return; + if (!data.attention?.length) { list.innerHTML = '
                                              Alles erledigt – aktuell ist keine Aktion nötig.
                                              '; return; } + const grouped = new Map(); + for (const item of data.attention) { + const current = grouped.get(item.type) || { ...item, count: 0 }; + current.count += 1; + grouped.set(item.type, current); + } + list.innerHTML = [...grouped.values()].slice(0, 5).map(item => ``).join(''); + list.querySelectorAll('[data-attention-id]').forEach(button => button.addEventListener('click', () => { + const id = Number(button.dataset.attentionId); + if (button.dataset.attentionType === 'publish_failed') switchToTab('publish'); + else showDetail(id); + })); +} + +function renderStudioActivity(data) { + const recent = document.getElementById('studio-activity'); + const completed = document.getElementById('studio-completed'); + const render = (container, items) => { + if (!container) return; + container.innerHTML = items.length ? items.map(item => ``).join('') : '
                                              Noch keine Aktivität
                                              '; + container.querySelectorAll('[data-activity-listing]').forEach(button => button.addEventListener('click', () => { const id = Number(button.dataset.activityListing); if (id) selectStudioListing(id); })); + }; + const completeTypes = new Set(['published', 'sold']); + render(recent, data.activity.filter(item => !completeTypes.has(item.type)).slice(0, 4)); + render(completed, data.activity.filter(item => completeTypes.has(item.type)).slice(0, 4)); +} + +function selectStudioListing(id) { + const item = getStudioItem(id); + if (!item) return; + selectedStudioListingId = id; + document.querySelectorAll('[data-studio-listing]').forEach(card => card.classList.toggle('selected', Number(card.dataset.studioListing) === id)); + renderStudioInspector(item); +} + +function renderStudioInspector(item) { + const empty = document.getElementById('studio-inspector-empty'); + const content = document.getElementById('studio-inspector-content'); + if (!content || !empty) return; + empty.classList.add('hidden'); + content.classList.remove('hidden'); + const photos = item.photos || []; + const hero = photos[0] ? `${escapeHtml(item.title)}` : '
                                              Kein Bild
                                              '; + const confidence = Math.max(0, Math.min(100, item.completeness || 0)); + const low = Math.max(0, (Number(item.price) || 0) * .92); + const high = Math.max(low, (Number(item.price) || 0) * 1.08); + content.innerHTML = `

                                              ${escapeHtml(item.title)}

                                              SKU  ${escapeHtml(item.sku || '')}
                                              ${item.status === 'sold' ? 'Verkauft' : 'Bereit'}
                                              ${hero}▧ ${photos.length}
                                              ${photos.length > 1 ? `
                                              ${photos.slice(0, 5).map(photo => ``).join('')}${photos.length > 5 ? `+${photos.length - 5}` : ''}
                                              ` : ''}
                                              Preis${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}

                                              AI-Konfidenz

                                              ${confidence}%
                                              ${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}

                                              Wichtige Attribute

                                              Marke
                                              ${escapeHtml(item.brand || '—')}
                                              Kategorie
                                              ${escapeHtml(item.category || '—')}
                                              Farbe
                                              ${escapeHtml(item.color || '—')}
                                              Größe
                                              ${escapeHtml(item.size || '—')}
                                              Zustand
                                              ${escapeHtml(item.condition || '—')}
                                              Lagerort
                                              ${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}
                                              ${item.price ? `

                                              Preisempfehlung

                                              ${escapeHtml(formatCurrency(low))} – ${escapeHtml(formatCurrency(high))}
                                              ` : ''}
                                              `; + content.querySelector('[data-inspector-action="review"]')?.addEventListener('click', () => showDetail(item.id)); + content.querySelector('[data-inspector-action="publish"]')?.addEventListener('click', () => { publishState.selectedId = item.id; switchToTab('publish'); }); + content.querySelector('[data-inspector-action="inventory"]')?.addEventListener('click', () => switchToTab('inventory')); +} + +async function loadToday() { + const ready = document.getElementById('today-ready-grid'); + if (ready) ready.innerHTML = '
                                              Studio Flow wird geladen…
                                              '; + try { + const data = await api('GET', '/api/dashboard/workflow?limit=12'); + studioWorkflowData = data; + renderTodaySummary(data); + renderTodayReady(data); + renderTodayScheduled(data); + renderStudioAttention(data); + renderStudioActivity(data); + const first = data.stages?.ready?.items?.[0] || data.stages?.scheduled?.items?.[0] || data.stages?.published?.items?.[0] || data.stages?.sold?.items?.[0]; + if (first && !selectedStudioListingId) selectStudioListing(first.id); + else if (selectedStudioListingId) { const selected = getStudioItem(selectedStudioListingId); if (selected) renderStudioInspector(selected); } + } catch (error) { + if (ready) ready.innerHTML = `
                                              Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}
                                              `; + } +} + + +// --- Photo Editor --- +let photoEditorState = { context: 'generator', platform: '', index: -1, original: null, rotation: 0, flipH: false, brightness: 0, contrast: 0, saturation: 0, cropping: false, cropStart: null, cropEnd: null, cropRect: null }; + +function getPhotoEditorPhotos() { + return photoEditorState.context === 'detail' ? (detailEditorState?.photos || []) : state.photos; +} + +function commitPhotoEditorResult(filename) { + const photos = getPhotoEditorPhotos(); + if (!filename || !photos[photoEditorState.index]) return; + photos[photoEditorState.index] = filename; + if (photoEditorState.context === 'detail') renderDetailPhotos(); + else renderPhotoPreview(); +} + +function getPhotoEditorPlatform() { + if (photoEditorState.context === 'detail') { + return document.getElementById('detail-platform')?.value || detailEditorState?.listing?.platform || 'allgemein'; + } + return state.platform || 'allgemein'; +} + +function setupPhotoEditor() { + document.getElementById('photo-editor-close').addEventListener('click', closePhotoEditor); + document.getElementById('pe-cancel').addEventListener('click', closePhotoEditor); + document.getElementById('photo-editor-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoEditor(); }); + + for (const prop of ['brightness', 'contrast', 'saturation']) { + const slider = document.getElementById(`pe-${prop}`); + const valEl = document.getElementById(`pe-${prop}-val`); + slider.addEventListener('input', () => { + photoEditorState[prop] = parseInt(slider.value); + valEl.textContent = slider.value; + renderPhotoEditorCanvas(); + }); + } + + document.getElementById('pe-rotate-left').addEventListener('click', () => { photoEditorState.rotation = (photoEditorState.rotation - 90) % 360; renderPhotoEditorCanvas(); }); + document.getElementById('pe-rotate-right').addEventListener('click', () => { photoEditorState.rotation = (photoEditorState.rotation + 90) % 360; renderPhotoEditorCanvas(); }); + document.getElementById('pe-flip-h').addEventListener('click', () => { photoEditorState.flipH = !photoEditorState.flipH; renderPhotoEditorCanvas(); }); + document.getElementById('pe-reset').addEventListener('click', () => { + photoEditorState = { ...photoEditorState, rotation: 0, flipH: false, brightness: 0, contrast: 0, saturation: 0 }; + document.getElementById('pe-brightness').value = 0; document.getElementById('pe-brightness-val').textContent = '0'; + document.getElementById('pe-contrast').value = 0; document.getElementById('pe-contrast-val').textContent = '0'; + document.getElementById('pe-saturation').value = 0; document.getElementById('pe-saturation-val').textContent = '0'; + renderPhotoEditorCanvas(); + }); + + document.getElementById('pe-apply').addEventListener('click', applyPhotoEdit); + + // Crop + document.getElementById('pe-crop-toggle').addEventListener('click', toggleCropMode); + document.getElementById('pe-crop-apply').addEventListener('click', applyCrop); + + const cropOverlay = document.getElementById('photo-editor-crop-overlay'); + cropOverlay.addEventListener('mousedown', cropMouseDown); + cropOverlay.addEventListener('mousemove', cropMouseMove); + cropOverlay.addEventListener('mouseup', cropMouseUp); + cropOverlay.addEventListener('touchstart', e => { e.preventDefault(); cropMouseDown(touchToMouse(e)); }, { passive: false }); + cropOverlay.addEventListener('touchmove', e => { e.preventDefault(); cropMouseMove(touchToMouse(e)); }, { passive: false }); + cropOverlay.addEventListener('touchend', e => { cropMouseUp(); }); +} + +function touchToMouse(e) { + const t = e.touches[0]; + return { clientX: t.clientX, clientY: t.clientY, target: e.target }; +} + +function toggleCropMode() { + photoEditorState.cropping = !photoEditorState.cropping; + photoEditorState.cropStart = null; + photoEditorState.cropEnd = null; + photoEditorState.cropRect = null; + const overlay = document.getElementById('photo-editor-crop-overlay'); + const btn = document.getElementById('pe-crop-toggle'); + const applyBtn = document.getElementById('pe-crop-apply'); + + if (photoEditorState.cropping) { + const mainCanvas = document.getElementById('photo-editor-canvas'); + overlay.width = mainCanvas.offsetWidth; + overlay.height = mainCanvas.offsetHeight; + overlay.style.top = mainCanvas.offsetTop + 'px'; + overlay.style.left = mainCanvas.offsetLeft + 'px'; + overlay.style.width = mainCanvas.offsetWidth + 'px'; + overlay.style.height = mainCanvas.offsetHeight + 'px'; + overlay.classList.remove('hidden'); + btn.style.background = 'var(--accent)'; + btn.style.color = 'white'; + } else { + overlay.classList.add('hidden'); + applyBtn.classList.add('hidden'); + btn.style.background = ''; + btn.style.color = ''; + clearCropOverlay(); + } +} + +function getCropCanvasCoords(e) { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const rect = overlay.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; +} + +function cropMouseDown(e) { + if (!photoEditorState.cropping) return; + photoEditorState._dragOrigin = getCropCanvasCoords(e); + photoEditorState._dragging = false; +} + +function cropMouseMove(e) { + if (!photoEditorState.cropping || !photoEditorState._dragOrigin) return; + const pos = getCropCanvasCoords(e); + const dx = Math.abs(pos.x - photoEditorState._dragOrigin.x); + const dy = Math.abs(pos.y - photoEditorState._dragOrigin.y); + if (!photoEditorState._dragging && (dx > 5 || dy > 5)) { + photoEditorState._dragging = true; + photoEditorState.cropStart = photoEditorState._dragOrigin; + photoEditorState.cropEnd = null; + photoEditorState.cropRect = null; + document.getElementById('pe-crop-apply').classList.add('hidden'); + } + if (photoEditorState._dragging) { + photoEditorState.cropEnd = pos; + drawCropOverlay(); + } +} + +function cropMouseUp() { + if (!photoEditorState.cropping) return; + if (photoEditorState._dragging && photoEditorState.cropStart && photoEditorState.cropEnd) { + const s = photoEditorState.cropStart; + const en = photoEditorState.cropEnd; + const x = Math.min(s.x, en.x), y = Math.min(s.y, en.y); + const w = Math.abs(en.x - s.x), h = Math.abs(en.y - s.y); + if (w > 10 && h > 10) { + photoEditorState.cropRect = { x, y, w, h }; + document.getElementById('pe-crop-apply').classList.remove('hidden'); + } + } + photoEditorState._dragOrigin = null; + photoEditorState._dragging = false; +} + +function drawCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const ctx = overlay.getContext('2d'); + const s = photoEditorState.cropStart; + const en = photoEditorState.cropEnd; + if (!s || !en) return; + + ctx.clearRect(0, 0, overlay.width, overlay.height); + // dim outside + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, 0, overlay.width, overlay.height); + // clear crop area + const x = Math.min(s.x, en.x), y = Math.min(s.y, en.y); + const w = Math.abs(en.x - s.x), h = Math.abs(en.y - s.y); + ctx.clearRect(x, y, w, h); + // border + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 3]); + ctx.strokeRect(x, y, w, h); + // rule of thirds + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([]); + for (let i = 1; i < 3; i++) { + ctx.beginPath(); ctx.moveTo(x + w * i / 3, y); ctx.lineTo(x + w * i / 3, y + h); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x, y + h * i / 3); ctx.lineTo(x + w, y + h * i / 3); ctx.stroke(); + } + // dimension label + const mainCanvas = document.getElementById('photo-editor-canvas'); + const scaleX = mainCanvas.width / overlay.width; + const scaleY = mainCanvas.height / overlay.height; + const realW = Math.round(w * scaleX); + const realH = Math.round(h * scaleY); + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + const label = `${realW} × ${realH}`; + ctx.font = '12px sans-serif'; + const tw = ctx.measureText(label).width; + ctx.fillRect(x + w / 2 - tw / 2 - 6, y + h - 24, tw + 12, 20); + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.fillText(label, x + w / 2, y + h - 10); +} + +function clearCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const ctx = overlay.getContext('2d'); + ctx.clearRect(0, 0, overlay.width, overlay.height); +} + +async function applyCrop() { + const cr = photoEditorState.cropRect; + if (!cr) return; + const mainCanvas = document.getElementById('photo-editor-canvas'); + const ov = document.getElementById('photo-editor-crop-overlay'); + const scaleX = mainCanvas.width / ov.width; + const scaleY = mainCanvas.height / ov.height; + + const sx = Math.round(cr.x * scaleX); + const sy = Math.round(cr.y * scaleY); + const sw = Math.round(cr.w * scaleX); + const sh = Math.round(cr.h * scaleY); + + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = sw; + tmpCanvas.height = sh; + tmpCanvas.getContext('2d').drawImage(mainCanvas, sx, sy, sw, sh, 0, 0, sw, sh); + + const blob = await new Promise(r => tmpCanvas.toBlob(r, 'image/jpeg', 0.92)); + const formData = new FormData(); + formData.append('photos', blob, 'cropped.jpg'); + const platform = getPhotoEditorPlatform(); + formData.append('platform', platform); + + try { + const res = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, { method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' }); + const data = await res.json(); + if (data.filenames?.[0]) { + commitPhotoEditorResult(data.filenames[0]); + toast('Bild zugeschnitten & gespeichert!'); + } + } catch (err) { toast('Fehler beim Speichern: ' + err.message); } + closePhotoEditor(); +} + +function openPhotoEditor(index, context = 'generator') { + photoEditorState.context = context; + const photos = context === 'detail' ? (detailEditorState?.photos || []) : state.photos; + const photo = photos[index]; + if (!photo) return; + photoEditorState = { context, platform: context === 'detail' ? (detailEditorState?.listing?.platform || '') : state.platform, index, original: null, rotation: 0, flipH: false, brightness: 0, contrast: 0, saturation: 0, cropping: false, cropStart: null, cropEnd: null, cropRect: null }; + document.getElementById('pe-brightness').value = 0; document.getElementById('pe-brightness-val').textContent = '0'; + document.getElementById('pe-contrast').value = 0; document.getElementById('pe-contrast-val').textContent = '0'; + document.getElementById('pe-saturation').value = 0; document.getElementById('pe-saturation-val').textContent = '0'; + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + photoEditorState.original = img; + renderPhotoEditorCanvas(); + document.getElementById('photo-editor-overlay').classList.remove('hidden'); + }; + img.src = photoUrl(photo); +} + +function renderPhotoEditorCanvas() { + const img = photoEditorState.original; + if (!img) return; + const canvas = document.getElementById('photo-editor-canvas'); + const ctx = canvas.getContext('2d'); + const rotated = Math.abs(photoEditorState.rotation % 180) === 90; + canvas.width = rotated ? img.height : img.width; + canvas.height = rotated ? img.width : img.height; + + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate(photoEditorState.rotation * Math.PI / 180); + if (photoEditorState.flipH) ctx.scale(-1, 1); + const b = 1 + photoEditorState.brightness / 100; + const c = 1 + photoEditorState.contrast / 100; + const s = 1 + photoEditorState.saturation / 100; + ctx.filter = `brightness(${b}) contrast(${c}) saturate(${s})`; + ctx.drawImage(img, -img.width / 2, -img.height / 2); + ctx.restore(); +} + +async function applyPhotoEdit() { + let canvas = document.getElementById('photo-editor-canvas'); + + // Apply pending crop first + if (photoEditorState.cropRect) { + const cr = photoEditorState.cropRect; + const ov = document.getElementById('photo-editor-crop-overlay'); + const scaleX = canvas.width / ov.width; + const scaleY = canvas.height / ov.height; + const sx = Math.round(cr.x * scaleX), sy = Math.round(cr.y * scaleY); + const sw = Math.round(cr.w * scaleX), sh = Math.round(cr.h * scaleY); + const tmp = document.createElement('canvas'); + tmp.width = sw; tmp.height = sh; + tmp.getContext('2d').drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); + canvas = tmp; + } + + const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.92)); + const formData = new FormData(); + formData.append('photos', blob, 'edited.jpg'); + const platform = getPhotoEditorPlatform(); + formData.append('platform', platform); + + const res = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, { method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' }); + const data = await res.json(); + + if (data.filenames?.[0]) { + commitPhotoEditorResult(data.filenames[0]); + toast('Foto aktualisiert!'); + } + closePhotoEditor(); +} + +function closePhotoEditor() { + document.getElementById('photo-editor-overlay').classList.add('hidden'); + photoEditorState.original = null; + photoEditorState.cropping = false; + document.getElementById('photo-editor-crop-overlay').classList.add('hidden'); + document.getElementById('pe-crop-apply').classList.add('hidden'); + const btn = document.getElementById('pe-crop-toggle'); + btn.style.background = ''; + btn.style.color = ''; +} + +// --- Photo Drag & Drop Sorting --- +function enablePhotoDragSort() { + const container = document.getElementById('photo-preview'); + let dragIdx = -1; + + container.addEventListener('dragstart', e => { + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + dragIdx = [...container.children].indexOf(thumb); + thumb.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const thumb = e.target.closest('.thumb'); + if (thumb) { + container.querySelectorAll('.thumb').forEach(t => t.classList.remove('drag-over')); + thumb.classList.add('drag-over'); + } + }); + + container.addEventListener('drop', e => { + e.preventDefault(); + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + const dropIdx = [...container.children].indexOf(thumb); + if (dragIdx >= 0 && dropIdx >= 0 && dragIdx !== dropIdx) { + const moved = state.photos.splice(dragIdx, 1)[0]; + state.photos.splice(dropIdx, 0, moved); + renderPhotoPreview(); + } + dragIdx = -1; + }); + + container.addEventListener('dragend', () => { + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + dragIdx = -1; + }); +} + +// --- Price Suggestion --- +async function fetchPriceSuggestion() { + const title = document.getElementById('result-title')?.value; + const category = document.getElementById('meta-category-picker')?.textContent; + if (!title) return; + try { + const data = await api('GET', `/api/price-suggest?title=${encodeURIComponent(title)}&category=${encodeURIComponent(category || '')}`); + if (data.suggestion) { + const priceInput = document.getElementById('meta-price'); + const currentPrice = parseFloat(priceInput.value); + const hint = document.getElementById('price-suggestion-hint'); + const html = `Preisvorschlag: ${data.suggestion}€ (${data.min}–${data.max}€, ${data.count} ähnliche)`; + if (hint) { + hint.innerHTML = html; + hint.classList.remove('hidden'); + } else { + const el = document.createElement('div'); + el.id = 'price-suggestion-hint'; + el.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:4px;cursor:pointer'; + el.innerHTML = html; + el.addEventListener('click', () => { priceInput.value = data.suggestion; }); + priceInput.parentElement.appendChild(el); + } + } + } catch {} +} + +// --- Backup & Restore (now handled in setupAdmin for system tab) --- +function setupBackup() { + // Legacy backup buttons in settings - no longer exist, backup is in admin > system tab +} + +// --- Browser Notifications --- +function setupNotifications() { + if (!('Notification' in window)) return; + if (Notification.permission === 'default') { + Notification.requestPermission(); + } +} + +function notify(title, body) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + new Notification(title, { body, icon: '/favicon.ico' }); +} + +// --- User Menu --- +let currentUser = null; + +async function setupUserMenu() { + try { + currentUser = await api('GET', '/api/me'); + if (!currentUser || currentUser.setupMode) { + document.getElementById('user-menu').classList.add('hidden'); + return; + } + _csrfToken = currentUser.csrf || getCsrf(); + + // Populate sidebar user area + const initials = currentUser.initials || currentUser.name?.[0]?.toUpperCase() || '?'; + const sidebarAvatar = document.getElementById('sidebar-avatar'); + if (sidebarAvatar) { + sidebarAvatar.textContent = initials; + sidebarAvatar.style.background = currentUser.avatar_color || 'var(--accent)'; + } + const sidebarName = document.getElementById('sidebar-name'); + if (sidebarName) sidebarName.textContent = currentUser.name || currentUser.email || ''; + const sidebarRole = document.getElementById('sidebar-role'); + if (sidebarRole) sidebarRole.textContent = currentUser.role || ''; + const topbarAvatar = document.getElementById('topbar-avatar'); + if (topbarAvatar) { + topbarAvatar.textContent = initials; + topbarAvatar.style.background = currentUser.avatar_color || 'var(--vd-ink)'; + topbarAvatar.title = currentUser.name || currentUser.email || ''; + } + + // Show/hide admin navigation for non-admins + if (currentUser.role !== 'admin') { + document.getElementById('nav-admin-main')?.classList.add('hidden'); + } + + // Logout + document.getElementById('sidebar-logout-btn')?.addEventListener('click', async () => { + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); + window.location.href = '/login.html'; + }); + } catch { + document.getElementById('sidebar-user')?.classList.add('hidden'); + } +} + +// --- Admin --- +function setupAdmin() { + // Admin tab switching + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', () => { + const target = tab.dataset.adminTab; + switchAdminTab(target); + if (target === 'users') loadAdminUsers(); + if (target === 'sessions') loadAdminSessions(); + if (target === 'smtp') loadSmtpSettings(); + if (target === 'system') loadSystemInfo(); + if (target === 'audit') loadAdminAudit(); + }); + }); + + document.getElementById('invite-user-btn')?.addEventListener('click', () => { + document.getElementById('invite-email').value = ''; + document.getElementById('invite-name').value = ''; + document.getElementById('invite-role').value = 'editor'; + document.getElementById('invite-result-wrap').classList.add('hidden'); + document.getElementById('invite-result-password').value = ''; + const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]'); + if (mlRadio) mlRadio.checked = true; + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + document.getElementById('invite-modal').classList.remove('hidden'); + }); + + document.getElementById('invite-copy-pw')?.addEventListener('click', () => { + const pw = document.getElementById('invite-result-password').value; + if (pw) { navigator.clipboard.writeText(pw); toast('Passwort kopiert!'); } + }); + + document.getElementById('invite-send-btn')?.addEventListener('click', sendInvite); + document.getElementById('edit-user-save-btn')?.addEventListener('click', saveUserEdit); + + // SMTP + document.getElementById('smtp-save-btn')?.addEventListener('click', saveSmtpSettings); + document.getElementById('smtp-test-btn')?.addEventListener('click', testSmtpConnection); + document.getElementById('smtp-pass-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('smtp-pass'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('smtp-pass-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // Edit user password toggle + document.getElementById('edit-user-pw-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('edit-user-password'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('edit-user-pw-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // System tab + document.getElementById('system-cleanup-btn')?.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/admin/sessions/cleanup'); + toast(res.message || 'Sessions bereinigt'); + loadSystemInfo(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + // Backup in system tab + document.getElementById('system-backup-btn')?.addEventListener('click', () => { + window.location.href = '/api/backup'; + }); + document.getElementById('system-restore-input')?.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + if (!confirm('Aktuelle Datenbank wird überschrieben! Fortfahren?')) { e.target.value = ''; return; } + const formData = new FormData(); + formData.append('backup', file); + try { + const res = await fetch('/api/restore', { method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' }); + const data = await res.json(); + toast(data.message || 'Backup eingespielt!'); + } catch (err) { toast('Fehler: ' + err.message); } + e.target.value = ''; + }); +} + +function switchAdminTab(target) { + document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.adminTab === target)); + document.querySelectorAll('.admin-panel').forEach(p => p.classList.remove('active')); + document.getElementById(`admin-${target}-panel`)?.classList.add('active'); +} + +function timeAgo(dateStr) { + if (!dateStr) return 'Nie'; + const diff = Date.now() - new Date(dateStr + 'Z').getTime(); + const min = Math.floor(diff / 60000); + if (min < 1) return 'Gerade eben'; + if (min < 60) return `Vor ${min}m`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `Vor ${hrs}h`; + const days = Math.floor(hrs / 24); + return `Vor ${days}d`; +} + +function getInitials(name, email) { + const src = name || email || '?'; + return src.split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); +} + +async function loadAdminUsers() { + try { + const users = await api('GET', '/api/admin/users'); + const container = document.getElementById('users-list'); + if (!users.length) { container.innerHTML = '
                                              Keine User vorhanden
                                              '; return; } + + container.innerHTML = users.map(u => { + const initials = getInitials(u.name, u.email); + const isOnline = u.last_active_at && (Date.now() - new Date(u.last_active_at + 'Z').getTime()) < 5 * 60 * 1000; + const dotClass = !u.active ? 'inactive' : isOnline ? 'online' : 'offline'; + const pwBadge = u.has_password + ? 'Passwort gesetzt' + : 'Kein Passwort'; + return `
                                              +
                                              ${initials}
                                              +
                                              +
                                              ${u.name || u.email} ${u.role} ${pwBadge}
                                              + +
                                              ${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(u.created_at + 'Z').toLocaleDateString('de-DE')}
                                              +
                                              +
                                              + + + ${u.id !== currentUser?.id ? `` : ''} +
                                              +
                                              `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function sendInvite() { + const email = document.getElementById('invite-email').value.trim(); + const name = document.getElementById('invite-name').value.trim(); + const role = document.getElementById('invite-role').value; + const method = document.querySelector('input[name="invite-method"]:checked')?.value || 'magic_link'; + if (!email) return toast('E-Mail erforderlich'); + + try { + const result = await api('POST', '/api/admin/users/invite', { email, name, role, method }); + + if (method === 'auto_password' && result.password) { + // Show generated password + const resultWrap = document.getElementById('invite-result-wrap'); + const pwInput = document.getElementById('invite-result-password'); + pwInput.value = result.password; + resultWrap.classList.remove('hidden'); + document.getElementById('invite-send-btn').textContent = 'Fertig'; + document.getElementById('invite-send-btn').onclick = () => { + document.getElementById('invite-modal').classList.add('hidden'); + resultWrap.classList.add('hidden'); + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + }; + toast(result.consoleFallback ? 'Account erstellt — Zugangsdaten in Konsole' : 'Account erstellt & Zugangsdaten gesendet'); + } else { + document.getElementById('invite-modal').classList.add('hidden'); + if (result.consoleFallback) { + toast('Einladung erstellt — Link in Server-Konsole'); + } else { + toast('Einladung gesendet an ' + email); + } + } + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function resendInvite(userId) { + try { + const result = await api('POST', '/api/admin/users/resend-invite', { user_id: userId }); + if (result.consoleFallback) { + toast('Link in Server-Konsole ausgegeben'); + } else { + toast('Einladung erneut gesendet'); + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function openEditUser(userId) { + try { + const users = await api('GET', '/api/admin/users'); + const user = users.find(u => u.id === userId); + if (!user) return; + document.getElementById('edit-user-id').value = user.id; + document.getElementById('edit-user-name').value = user.name || ''; + document.getElementById('edit-user-role').value = user.role; + document.getElementById('edit-user-active').value = user.active ? '1' : '0'; + document.getElementById('edit-user-password').value = ''; + document.getElementById('edit-user-password').type = 'password'; + document.getElementById('edit-user-pw-toggle').textContent = 'Zeigen'; + + // Password status + const pwStatus = document.getElementById('edit-user-pw-status'); + if (user.has_password) { + pwStatus.textContent = 'Passwort gesetzt'; + pwStatus.className = 'pw-status-badge has-pw'; + } else { + pwStatus.textContent = 'Kein Passwort'; + pwStatus.className = 'pw-status-badge no-pw'; + } + + // Login history + const historyEl = document.getElementById('edit-user-login-history'); + historyEl.classList.add('hidden'); + historyEl.innerHTML = ''; + + const showHistBtn = document.getElementById('edit-user-show-history'); + showHistBtn.onclick = async () => { + if (!historyEl.classList.contains('hidden')) { + historyEl.classList.add('hidden'); + showHistBtn.textContent = 'Login-Verlauf anzeigen'; + return; + } + try { + const history = await api('GET', `/api/admin/users/${userId}/login-history`); + if (!history.length) { + historyEl.innerHTML = '
                                              Keine Login-Einträge
                                              '; + } else { + historyEl.innerHTML = history.map(h => { + const dt = new Date(h.created_at + 'Z').toLocaleString('de-DE'); + const statusClass = h.success ? 'success' : 'failed'; + const statusText = h.success ? 'Erfolgreich' : 'Fehlgeschlagen'; + return ``; + }).join(''); + } + historyEl.classList.remove('hidden'); + showHistBtn.textContent = 'Verlauf ausblenden'; + } catch (err) { toast('Fehler: ' + err.message); } + }; + + document.getElementById('edit-user-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveUserEdit() { + const id = document.getElementById('edit-user-id').value; + const name = document.getElementById('edit-user-name').value.trim(); + const role = document.getElementById('edit-user-role').value; + const active = parseInt(document.getElementById('edit-user-active').value); + const password = document.getElementById('edit-user-password').value; + + try { + await api('PUT', `/api/admin/users/${id}`, { name, role, active }); + + // Reset password if provided + if (password) { + if (password.length < 6) { toast('Passwort muss mindestens 6 Zeichen lang sein'); return; } + await api('POST', `/api/admin/users/${id}/reset-password`, { password }); + toast('User aktualisiert & Passwort zurückgesetzt'); + } else { + toast('User aktualisiert'); + } + + document.getElementById('edit-user-modal').classList.add('hidden'); + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function confirmDeleteUser(userId, email) { + if (!confirm(`User "${email}" wirklich löschen? Alle Sessions werden beendet.`)) return; + try { + await api('DELETE', `/api/admin/users/${userId}`); + toast('User gelöscht'); + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminSessions() { + try { + const sessions = await api('GET', '/api/admin/sessions'); + const container = document.getElementById('sessions-list'); + if (!sessions.length) { container.innerHTML = '
                                              Keine aktiven Sessions
                                              '; return; } + + container.innerHTML = sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                                              +
                                              + ${s.name || s.email} ${s.role} +
                                              ${ua}
                                              +
                                              IP: ${s.ip || 'unbekannt'}
                                              +
                                              +
                                              + ${timeAgo(s.created_at)}
                                              + Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')} +
                                              + +
                                              `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function killSession(sessionId) { + try { + await api('DELETE', `/api/admin/sessions/${sessionId}`); + toast('Session beendet'); + loadAdminSessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminAudit() { + try { + const logs = await api('GET', '/api/admin/audit?limit=200'); + const container = document.getElementById('audit-list'); + if (!logs.length) { container.innerHTML = '
                                              Noch keine Audit-Einträge
                                              '; return; } + + container.innerHTML = logs.map(l => { + const actionParts = (l.action || '').split('.'); + const category = actionParts[0] || 'other'; + return `
                                              +
                                              ${new Date(l.created_at + 'Z').toLocaleString('de-DE')}
                                              +
                                              ${l.user_email || '—'}
                                              +
                                              + ${l.action} + ${l.details ? `${l.details}` : ''} +
                                              +
                                              `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +// --- SMTP Settings --- +async function loadSmtpSettings() { + try { + const smtp = await api('GET', '/api/admin/smtp'); + document.getElementById('smtp-host').value = smtp.smtp_host || ''; + document.getElementById('smtp-port').value = smtp.smtp_port || '587'; + document.getElementById('smtp-user').value = smtp.smtp_user || ''; + document.getElementById('smtp-pass').value = ''; + document.getElementById('smtp-pass').placeholder = smtp.smtp_pass ? 'Gesetzt (leer = beibehalten)' : 'Passwort'; + document.getElementById('smtp-from').value = smtp.smtp_from || ''; + + const dot = document.getElementById('smtp-status-dot'); + const text = document.getElementById('smtp-status-text'); + if (smtp.configured) { + dot.className = 'smtp-status-dot configured'; + text.textContent = `SMTP konfiguriert (Quelle: ${smtp.source === 'db' ? 'Datenbank' : smtp.source === 'env' ? '.env Datei' : 'Nicht konfiguriert'})`; + } else { + dot.className = 'smtp-status-dot not-configured'; + text.textContent = 'SMTP nicht konfiguriert — E-Mails werden in der Konsole ausgegeben'; + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveSmtpSettings() { + const data = { + smtp_host: document.getElementById('smtp-host').value.trim(), + smtp_port: document.getElementById('smtp-port').value.trim(), + smtp_user: document.getElementById('smtp-user').value.trim(), + smtp_from: document.getElementById('smtp-from').value.trim(), + }; + const pass = document.getElementById('smtp-pass').value; + if (pass) data.smtp_pass = pass; + + try { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = true; btn.textContent = 'Speichere...'; + await api('PUT', '/api/admin/smtp', data); + toast('SMTP-Einstellungen gespeichert'); + loadSmtpSettings(); + } catch (err) { toast('Fehler: ' + err.message); } + finally { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = false; btn.textContent = 'SMTP speichern'; + } +} + +async function testSmtpConnection() { + const btn = document.getElementById('smtp-test-btn'); + btn.disabled = true; btn.textContent = 'Sende Testmail...'; + try { + const res = await api('POST', '/api/admin/smtp/test'); + toast(res.message || 'Testmail gesendet!'); + } catch (err) { toast('Fehler: ' + err.message); } + finally { btn.disabled = false; btn.textContent = 'Verbindung testen'; } +} + +// --- System Info --- +async function loadSystemInfo() { + try { + const sys = await api('GET', '/api/admin/system'); + document.getElementById('system-info-cards').innerHTML = ` +
                                              +
                                              Server Uptime
                                              +
                                              ${sys.uptime}
                                              +
                                              +
                                              +
                                              Benutzer
                                              +
                                              ${sys.total_users}
                                              +
                                              +
                                              +
                                              Listings
                                              +
                                              ${sys.total_listings}
                                              +
                                              +
                                              +
                                              Aktive Sessions
                                              +
                                              ${sys.active_sessions}
                                              +
                                              +
                                              +
                                              Datenbankgröße
                                              +
                                              ${sys.db_size}
                                              +
                                              +
                                              +
                                              Node.js
                                              +
                                              ${sys.node_version}
                                              +
                                              +
                                              +
                                              Speicher (RAM)
                                              +
                                              ${sys.memory_mb} MB
                                              +
                                              +
                                              +
                                              Plattform
                                              +
                                              ${sys.platform}
                                              +
                                              + `; + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadMySessions() { + try { + const sessions = await api('GET', '/api/my/sessions'); + const container = document.getElementById('my-sessions-list'); + container.innerHTML = sessions.length ? sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                                              +
                                              +
                                              ${ua}
                                              +
                                              IP: ${s.ip || 'unbekannt'}
                                              +
                                              +
                                              ${timeAgo(s.created_at)}
                                              + +
                                              `; + }).join('') : '
                                              Keine aktiven Sessions
                                              '; + document.getElementById('my-sessions-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function endMySession(sessionId) { + try { + await api('DELETE', `/api/my/sessions/${sessionId}`); + toast('Session beendet'); + loadMySessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +function parseUA(ua) { + if (!ua) return 'Unbekannt'; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Firefox')) return 'Firefox'; + if (ua.includes('Safari')) return 'Safari'; + if (ua.includes('Edge')) return 'Edge'; + return ua.substring(0, 40); +} + +init(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/apple-touch-icon.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/apple-touch-icon.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-192x192.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-192x192.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-512x512.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/android-chrome-512x512.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/apple-touch-icon.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/apple-touch-icon.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/browserconfig.xml b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/browserconfig.xml new file mode 100644 index 0000000..fa0e64f --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #faf6f2 + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-16x16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-16x16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-32x32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-32x32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-48x48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon-48x48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon.ico b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/favicon.ico differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/html-snippet.txt b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/html-snippet.txt new file mode 100644 index 0000000..753dafb --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/html-snippet.txt @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/mstile-150x150.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/mstile-150x150.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/mstile-150x150.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/site.webmanifest b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/site.webmanifest new file mode 100644 index 0000000..4d5086d --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/favicons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Vendoo", + "short_name": "Vendoo", + "icons": [ + { + "src": "/brand/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/brand/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#FAF6F2", + "background_color": "#FAF6F2", + "display": "standalone" +} \ No newline at end of file diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-120x120.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-120x120.png new file mode 100644 index 0000000..643e525 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-120x120.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-128x128.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-128x128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-128x128.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-144x144.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-144x144.png new file mode 100644 index 0000000..8467167 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-144x144.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-152x152.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-152x152.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-152x152.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-167x167.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-167x167.png new file mode 100644 index 0000000..7bd6ca2 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-167x167.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-16x16.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-16x16.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-180x180.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-180x180.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-180x180.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-192x192.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-192x192.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-256x256.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-256x256.png new file mode 100644 index 0000000..98a4ed0 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-256x256.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-32x32.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-32x32.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-384x384.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-384x384.png new file mode 100644 index 0000000..be778a5 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-384x384.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-48x48.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-48x48.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-512x512.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-512x512.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-64x64.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-64x64.png new file mode 100644 index 0000000..5f7da83 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-64x64.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-72x72.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-72x72.png new file mode 100644 index 0000000..9882902 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-72x72.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-96x96.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-96x96.png new file mode 100644 index 0000000..32b38d3 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-96x96.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-original-1254.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-original-1254.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/icons/vendoo-icon-original-1254.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-cropped.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-cropped.png new file mode 100644 index 0000000..329e51a Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-cropped.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-original.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-original.png new file mode 100644 index 0000000..44ac64d Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-original.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-transparent.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-transparent.png new file mode 100644 index 0000000..2449e44 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-logo-transparent.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-wordmark-transparent.png b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-wordmark-transparent.png new file mode 100644 index 0000000..ad76562 Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/brand/logo/vendoo-wordmark-transparent.png differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/favicon.ico b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/favicon.ico differ diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/index.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/index.html new file mode 100644 index 0000000..36691dc --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/index.html @@ -0,0 +1,1490 @@ + + + + + + Vendoo + + + + + + + + + + + + + + + +
                                              +
                                              +
                                              + + +
                                              +

                                              Dashboard

                                              + Analytics +
                                              +
                                              + + + +
                                              + + + +
                                              +
                                              + +
                                              + +
                                              +
                                              +
                                              +

                                              Business Intelligence

                                              +

                                              Dashboard

                                              +

                                              Dein Überblick über Umsatz, Listings, Lagerbestand und Performance.

                                              +
                                              +
                                              + + +
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              Umsatz heuteAktueller Tageswert
                                              +
                                              Verkäufe 7 TageAbgeschlossene Verkäufe
                                              +
                                              Aktive ListingsAktuell im Bestand
                                              +
                                              EntwürfeNoch nicht veröffentlicht
                                              +
                                              LagerwertGeschätzter Gesamtwert
                                              +
                                              Publish-ErfolgsquoteErfolgreiche Versuche
                                              +
                                              + +
                                              +
                                              +
                                              Umsatzentwicklung
                                              Letzte 30 Tage
                                              +
                                              +
                                              +
                                              +
                                              Umsatz nach Plattform
                                              +
                                              +
                                              +
                                              +
                                              Umsatz nach KategorieVerteilung
                                              +
                                              +
                                              +
                                              + +
                                              +
                                              Heute im FokusDirekt handlungsrelevant
                                              +
                                              +
                                              + +
                                              +
                                              +
                                              Top Seller / BestsellerNach Umsatz
                                              +
                                              ArtikelSKUPlattformUmsatzVerkäufeStatus
                                              +
                                              +
                                              +
                                              SchnellaktionenHäufige Aufgaben
                                              +
                                              + + + + + +
                                              +
                                              +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              +
                                              +

                                              Commerce Workbench

                                              +

                                              Studio Flow

                                              +

                                              Deine nächsten Schritte – klar priorisiert und ohne Kartenüberladung.

                                              +
                                              +
                                              + +
                                              + + + + + +
                                              + +
                                              +
                                              + + + + + +
                                              + +
                                              + +
                                              +
                                              +
                                              +

                                              Bereit für den nächsten Schritt

                                              0
                                              +
                                              +
                                              + +
                                              +
                                              +

                                              Geplant & in Warteschlange

                                              0
                                              +
                                              ArtikelPlattformGeplant amStatusAktion
                                              +
                                              +
                                              +
                                              !

                                              Benötigt Aufmerksamkeit

                                              0
                                              +
                                              + +
                                              +
                                              + +
                                              +

                                              Aktivität

                                              +

                                              Letzte Aktivitäten

                                              Abgeschlossene Aktionen

                                              +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              +
                                              +

                                              Listing Studio

                                              +

                                              Neues Listing

                                              +

                                              Fotos erfassen, AI gezielt steuern und das Ergebnis ohne Kontextwechsel fertigstellen.

                                              +
                                              +
                                              +
                                              + 1 Fotos + 2 AI + 3 Prüfen +
                                              + +
                                              +
                                              + +
                                              +
                                              +
                                              +
                                              + 01 +
                                              +

                                              Fotos & Medien

                                              +

                                              Das erste Bild wird als Cover verwendet.

                                              +
                                              +
                                              + 0 / 10 +
                                              + +
                                              +
                                              +
                                              + +
                                              + Noch keine Produktfotos + Gute Bilder erhöhen Erkennungsqualität und Verkaufschance. +
                                              + + +
                                              + +
                                              + +
                                              + +
                                              +

                                              Fotos hierher ziehen
                                              oder klicken zum Auswählen

                                              + JPG, PNG, WEBP oder GIF · maximal 10 Bilder +
                                              + + + +
                                              + + + + +
                                              +
                                              +
                                              + AI +
                                              +

                                              AI-Model-Fotos

                                              +

                                              Für Kleidung ohne Person auf dem Originalfoto. Vendoo erzeugt 1 bis 3 sichere Zusatzbilder und blockiert Unterwäsche, Bademode, transparente Erotik-Looks und Kinderkleidung.

                                              +
                                              +
                                              + Safe +
                                              +
                                              + + + + +
                                              +
                                              Wartet auf Produktfotos.
                                              + +
                                              + +
                                              +
                                              +
                                              + + Foto-Check +
                                              + 0 / 5 +
                                              +
                                                +
                                              • Mindestens drei Perspektiven
                                              • +
                                              • Klares Coverfoto
                                              • +
                                              • Detail- und Etikettfoto
                                              • +
                                              • Ruhiger Hintergrund empfohlen
                                              • +
                                              • Reihenfolge per Drag & Drop
                                              • +
                                              +
                                              +
                                              + +
                                              +
                                              +
                                              + 02 +
                                              +

                                              AI-Generator

                                              +

                                              Lege Ziel, Sprache und Tonalität fest.

                                              +
                                              +
                                              + Assistiert +
                                              + +
                                              +
                                              +
                                              + + Optimiert Aufbau, Ton und Pflichtfelder. +
                                              +
                                              +
                                              + +
                                              +
                                              + + Wähle den bevorzugten Analyse-Dienst. +
                                              +
                                              +
                                              + + + +
                                              +
                                              +
                                              + +
                                              + +
                                              +
                                              +
                                              + +
                                              + +
                                              +
                                              + +
                                              +
                                              + + Übernimmt Plattform, Sprache, Hinweise und Standard-Tags. +
                                              + + +
                                              + +
                                              +
                                              +
                                              + + Nur relevante Besonderheiten für diesen Artikel. +
                                              + 0 / 500 +
                                              + +
                                              + Ton: freundlich & präzise + Detailgrad: hoch + Keine Übertreibungen +
                                              +
                                              +
                                              + + + +
                                              +
                                              + +
                                              + Fotos fehlen + Lade mindestens ein Produktfoto hoch. +
                                              +
                                              + +
                                              +
                                              + + +
                                              +
                                              + + + +
                                              +
                                              +
                                              +

                                              Listing-Verwaltung

                                              +

                                              Listings / Historie

                                              +

                                              Überblick über alle erstellten, geplanten und veröffentlichten Listings.

                                              +
                                              +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              +
                                              + +
                                              + + + + + + +
                                              + +
                                              +
                                              + +
                                              + + + + + + + +
                                              + +
                                              + +
                                              +
                                              +
                                              Auswahl
                                              +
                                              Artikel
                                              +
                                              Plattform
                                              +
                                              AI-Provider
                                              +
                                              Preis
                                              +
                                              Status
                                              +
                                              Lagerort
                                              +
                                              Veröffentlicht
                                              +
                                              Aktionen
                                              +
                                              +
                                              +
                                              + + +
                                              + + + + + + +
                                              + + +
                                              +
                                              +

                                              Listing-Vorlagen

                                              + +
                                              +
                                              + +
                                              + + +
                                              +
                                              +
                                              +

                                              Commerce Control

                                              +

                                              Publish-Center

                                              +

                                              Bereite Listings für den Verkauf vor und veröffentliche sie sicher auf deinen Marktplätzen.

                                              +
                                              +
                                              + + +
                                              +
                                              + + + +
                                              + + +
                                              +
                                              +
                                              + + +
                                              + + +
                                              + + +
                                              +
                                              +

                                              Papierkorb

                                              +
                                              + Einträge werden nach 30 Tagen automatisch gelöscht + +
                                              +
                                              +
                                              +
                                              + + +
                                              +
                                              +

                                              Lagerverwaltung

                                              +
                                              + +
                                              +
                                              +
                                              +
                                              + +
                                              + + +
                                              +

                                              Gebührenrechner

                                              +
                                              + + +
                                              +
                                              +
                                              + + +
                                              +
                                              +
                                              +

                                              User-Verwaltung

                                              + +
                                              + +
                                              + + + + + +
                                              + + +
                                              +
                                              +
                                              + + +
                                              +
                                              +
                                              + + +
                                              +
                                              +
                                              + + Lade... +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + +
                                              + + +
                                              +
                                              +
                                              + + + E-Mail-Adresse, die als Absender angezeigt wird +
                                              +
                                              + + +
                                              +
                                              +
                                              + + +
                                              +
                                              +
                                              + +
                                              +
                                              +

                                              Backup & Restore

                                              +

                                              Datenbank sichern oder wiederherstellen

                                              +
                                              + + +
                                              +
                                              +
                                              + + +
                                              +
                                              +
                                              +
                                              + + + + + + + + + +
                                              + + +
                                              +
                                              +
                                              + +
                                              + + +
                                              + Wird sofort aktiv nach dem Speichern +
                                              +
                                              + +
                                              + + +
                                              + Wird sofort aktiv nach dem Speichern +
                                              +
                                              + +
                                              + + +
                                              + Kostenlos registrieren auf openrouter.ai — enthält Free-Modelle +
                                              +
                                              + +
                                              + + +
                                              + Erstelle einen Key auf etsy.com/developers + +
                                              +
                                              +

                                              eBay.de Integration

                                              + +
                                              + + +
                                              + +
                                              + + +
                                              + + +
                                              + +
                                              + Kostenlos auf developer.ebay.com registrieren. RuName unter User Tokens erstellen. + +
                                              +
                                              + + + Lokale AI via Ollama — muss separat installiert und gestartet sein +
                                              + +
                                              + +
                                              + + +
                                              + Diese Adresse wird in QR-Codes verwendet. Smartphone und Vendoo-PC müssen im selben Netzwerk sein. +
                                              Noch keine LAN-Adresse geprüft.
                                              +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + + + Wird automatisch in jede Listing-Beschreibung eingebaut +
                                              + +
                                              +
                                              +
                                              +

                                              AI-Model-Fotos

                                              +

                                              Provider-Auswahl, Free-Model-Abfrage und Fallback für automatisch erzeugte Model-Fotos.

                                              +
                                              + Auto +
                                              + +
                                              +
                                              + + + Empfohlen: Auto. Dann wird zuerst nach kostenlosen OpenRouter-Bildmodellen gesucht. +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + + +
                                              +
                                              + +
                                              + + + + +
                                              + +
                                              +
                                              + +
                                              + + +
                                              + Wenn leer bzw. automatisch, nimmt Vendoo das erste passende Modell aus der aktuellen OpenRouter-Liste. +
                                              +
                                              + + + Wird genutzt, wenn kein geeignetes OpenRouter-Modell gefunden wird oder OpenAI explizit gewählt wurde. +
                                              +
                                              + +
                                              Noch keine OpenRouter-Modelle geladen.
                                              + +
                                              + + + + +
                                              +
                                              +
                                              + + + + +
                                              + + + +
                                              + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/login.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/login.html new file mode 100644 index 0000000..dc87a72 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/login.html @@ -0,0 +1,247 @@ + + + + + + Vendoo — Login + + + + + + + + + + + + + + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload-expired.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload-expired.html new file mode 100644 index 0000000..6ba98a9 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload-expired.html @@ -0,0 +1,9 @@ +Upload-Link abgelaufen + + + + + + + +
                                              VendooMobiler Foto-Upload
                                              !

                                              Upload-Link abgelaufen

                                              Erzeuge in Vendoo einen neuen QR-Code und scanne ihn erneut.

                                              diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.css new file mode 100644 index 0000000..6996f00 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.css @@ -0,0 +1,65 @@ +:root{--bg:#f4f0e8;--surface:#fffdf9;--surface-2:#f9f6f0;--line:#ded7cc;--ink:#24221e;--muted:#777168;--brand:#e63b17;--brand-dark:#c92e0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#be4037;--shadow:0 24px 60px rgba(44,38,30,.12)}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100dvh;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.07),transparent 35%),var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.mobile-upload-shell{width:min(100%,540px);min-height:100dvh;margin:auto;padding:calc(18px + env(safe-area-inset-top)) 16px calc(24px + env(safe-area-inset-bottom));display:grid;align-content:start;gap:14px}.mobile-upload-brand{display:flex;align-items:center;gap:11px;padding:2px 3px 7px}.mobile-upload-brand>div{display:grid;gap:1px}.mobile-upload-brand strong{font-size:19px;letter-spacing:-.04em}.mobile-upload-brand span:not(.mobile-upload-logo){font-size:11px;color:var(--muted)}.mobile-upload-logo{position:relative;width:38px;height:38px;display:block}.mobile-upload-logo i{position:absolute;left:5px;top:5px;width:9px;height:28px;border-radius:6px;background:var(--brand);transform:rotate(-30deg)}.mobile-upload-logo b{position:absolute;left:19px;top:4px;width:15px;height:17px;border-radius:4px 7px 7px 5px;background:var(--brand);transform:rotate(14deg)}.mobile-upload-logo b:after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.mobile-upload-logo em{position:absolute;right:0;bottom:0;width:15px;height:15px;display:grid;place-items:center;border-radius:50%;background:#30302d;color:#fff;font-size:9px;font-style:normal}.mobile-upload-card{padding:20px;border:1px solid var(--line);border-radius:20px;background:rgba(255,253,249,.94);box-shadow:0 10px 35px rgba(44,38,30,.06);backdrop-filter:blur(12px)}.mobile-upload-card.hero{padding:24px}.eyebrow{display:block;margin-bottom:8px;color:var(--brand);font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.mobile-upload-card h1,.mobile-upload-card h2{margin:0;color:var(--ink);font-size:clamp(25px,7vw,34px);line-height:1.04;letter-spacing:-.05em}.mobile-upload-card h2{font-size:24px}.mobile-upload-card p{margin:11px 0 0;color:var(--muted);font-size:14px;line-height:1.55}.session-status{display:flex;align-items:center;gap:8px;margin-top:18px;padding:10px 12px;border-radius:11px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:700}.session-status span{width:8px;height:8px;border-radius:50%;background:#aaa}.session-status.ok{background:var(--success-soft);color:var(--success)}.session-status.ok span{background:var(--success)}.camera-action{min-height:180px;display:grid;place-items:center;align-content:center;gap:7px;padding:20px;border:1.5px dashed #cfc6b8;border-radius:16px;background:linear-gradient(145deg,#fff,var(--surface-2));text-align:center;cursor:pointer}.camera-action:active{border-color:var(--brand);background:var(--brand-soft)}.camera-icon{width:48px;height:48px;display:grid;place-items:center;border-radius:15px;background:var(--brand);color:#fff;font-size:28px;box-shadow:0 12px 25px rgba(230,59,23,.2)}.camera-action strong{font-size:15px}.camera-action small{color:var(--muted);font-size:11px}.upload-meta{display:flex;justify-content:space-between;gap:12px;margin:14px 2px 9px;color:var(--muted);font-size:11px}.mobile-preview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.mobile-preview figure{position:relative;aspect-ratio:1;margin:0;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}.mobile-preview img{width:100%;height:100%;object-fit:contain}.mobile-preview button{position:absolute;right:5px;top:5px;width:25px;height:25px;border:0;border-radius:50%;background:rgba(28,27,24,.8);color:#fff;font-size:15px}.mobile-primary,.mobile-secondary{width:100%;min-height:50px;margin-top:14px;border-radius:13px;font:inherit;font-size:14px;font-weight:800;cursor:pointer}.mobile-primary{border:0;background:var(--brand);color:#fff;box-shadow:0 14px 28px rgba(230,59,23,.2)}.mobile-primary:disabled{opacity:.4;box-shadow:none}.mobile-primary:not(:disabled):active{background:var(--brand-dark)}.mobile-secondary{border:1px solid var(--line);background:var(--surface);color:var(--ink)}.upload-progress{height:7px;margin-top:13px;overflow:hidden;border-radius:999px;background:#e7e1d8}.upload-progress span{display:block;width:45%;height:100%;border-radius:inherit;background:var(--brand);animation:progress 1.1s ease-in-out infinite alternate}@keyframes progress{to{transform:translateX(122%)}}.success,.error{text-align:center}.success-mark,.error-mark{width:62px;height:62px;display:grid;place-items:center;margin:0 auto 15px;border-radius:20px;font-size:31px;font-weight:900}.success-mark{background:var(--success-soft);color:var(--success)}.error-mark{background:rgba(190,64,55,.1);color:var(--danger)}footer{padding:4px 18px;color:var(--muted);font-size:10px;line-height:1.5;text-align:center}.hidden{display:none!important}@media(min-width:460px){.mobile-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:360px){.mobile-upload-shell{padding-inline:10px}.mobile-upload-card{padding:16px}.mobile-upload-card.hero{padding:19px}.mobile-preview{grid-template-columns:repeat(2,minmax(0,1fr))}} +.mobile-upload-brand-icon{width:40px;height:40px;display:block;object-fit:contain;flex:0 0 auto} + +.mobile-source-actions, +.mobile-more-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.camera-action { + width: 100%; + min-height: 132px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--brand-soft); + color: var(--ink); + padding: 18px 14px; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + text-align: center; + cursor: pointer; + font: inherit; +} + +.camera-action.secondary-source { + background: var(--surface-2); +} + +.camera-action:disabled, +.mobile-secondary:disabled { + opacity: .5; + cursor: not-allowed; +} + +.mobile-preview-fallback { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-content: center; + gap: 4px; + padding: 8px; + text-align: center; + background: var(--surface-2); + color: var(--muted); + font-size: 11px; +} + +.mobile-preview-fallback strong { + color: var(--ink); + font-size: 12px; +} + +@media (max-width: 420px) { + .mobile-source-actions, + .mobile-more-actions { + grid-template-columns: 1fr; + } + + .camera-action { + min-height: 112px; + } +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.html b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.html new file mode 100644 index 0000000..54152a5 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.html @@ -0,0 +1,77 @@ + + + + + + + Vendoo Foto-Upload + + + + + + + + + + + +
                                              +
                                              + +
                                              VendooMobiler Foto-Upload
                                              +
                                              + +
                                              + Direkt vom Smartphone +

                                              Produktfotos aufnehmen

                                              +

                                              Fotografiere den Artikel oder wähle vorhandene Bilder. Sie erscheinen automatisch im geöffneten Vendoo-Studio.

                                              +
                                              Upload-Link wird geprüft …
                                              +
                                              + + + + + + + +
                                              Die Bilder werden ausschließlich an deine Vendoo-Instanz übertragen.
                                              +
                                              + + + diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.js b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.js new file mode 100644 index 0000000..1ad5b2b --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/mobile-upload.js @@ -0,0 +1,169 @@ +(() => { + 'use strict'; + + const token = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || ''); + const cameraInput = document.getElementById('camera-input'); + const libraryInput = document.getElementById('library-input'); + const preview = document.getElementById('mobile-preview'); + const uploadButton = document.getElementById('upload-btn'); + const uploadPanel = document.getElementById('upload-panel'); + const successPanel = document.getElementById('success-panel'); + const errorPanel = document.getElementById('error-panel'); + const status = document.getElementById('session-status'); + const count = document.getElementById('photo-count'); + const expiresCopy = document.getElementById('expires-copy'); + const progress = document.getElementById('upload-progress'); + const sourceButtons = [ + document.getElementById('open-camera-btn'), + document.getElementById('open-library-btn'), + document.getElementById('add-camera-btn'), + document.getElementById('add-library-btn'), + document.getElementById('success-camera-btn'), + document.getElementById('success-library-btn'), + ].filter(Boolean); + + let selected = []; + let serverFiles = []; + let remaining = 10; + + function showError(message) { + uploadPanel.classList.add('hidden'); + successPanel.classList.add('hidden'); + errorPanel.classList.remove('hidden'); + document.getElementById('error-copy').textContent = message; + status.className = 'session-status'; + status.innerHTML = 'Upload nicht verfügbar'; + } + + function openCamera() { + if (remaining - selected.length <= 0) return; + cameraInput.click(); + } + + function openLibrary() { + if (remaining - selected.length <= 0) return; + libraryInput.click(); + } + + function render() { + preview.innerHTML = ''; + selected.forEach((file, index) => { + const figure = document.createElement('figure'); + const image = document.createElement('img'); + image.alt = file.name || `Foto ${index + 1}`; + const objectUrl = URL.createObjectURL(file); + image.src = objectUrl; + image.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true }); + image.addEventListener('error', () => { + URL.revokeObjectURL(objectUrl); + image.remove(); + const fallback = document.createElement('div'); + fallback.className = 'mobile-preview-fallback'; + fallback.innerHTML = `Bild ${index + 1}${file.name || 'Foto ausgewählt'}`; + figure.prepend(fallback); + }, { once: true }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.textContent = '×'; + remove.setAttribute('aria-label', `${file.name || 'Bild'} entfernen`); + remove.addEventListener('click', () => { + selected.splice(index, 1); + render(); + }); + figure.append(image, remove); + preview.appendChild(figure); + }); + + const total = serverFiles.length + selected.length; + count.textContent = `${total} von 10 Bildern`; + uploadButton.disabled = selected.length === 0; + uploadButton.textContent = selected.length + ? `${selected.length} ${selected.length === 1 ? 'Foto' : 'Fotos'} zu Vendoo senden` + : 'Fotos zu Vendoo senden'; + + const noSlots = remaining - selected.length <= 0; + sourceButtons.forEach(button => { button.disabled = noSlots; }); + } + + function addFiles(files) { + const candidates = [...files].filter(file => file.type.startsWith('image/') || /\.(heic|heif)$/i.test(file.name || '')); + const slots = Math.max(0, remaining - selected.length); + selected.push(...candidates.slice(0, slots)); + render(); + } + + async function loadSession() { + try { + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}`, { cache: 'no-store' }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload-Link konnte nicht geladen werden.'); + serverFiles = data.files || []; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + status.className = 'session-status ok'; + status.innerHTML = 'Mit Vendoo verbunden'; + uploadPanel.classList.remove('hidden'); + const expires = new Date(String(data.expires_at).replace(' ', 'T') + (String(data.expires_at).includes('Z') ? '' : 'Z')); + expiresCopy.textContent = Number.isNaN(expires.getTime()) + ? '' + : `gültig bis ${expires.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + render(); + } catch (error) { + showError(error.message); + } + } + + async function upload() { + if (!selected.length) return; + uploadButton.disabled = true; + sourceButtons.forEach(button => { button.disabled = true; }); + progress.classList.remove('hidden'); + try { + const form = new FormData(); + selected.forEach(file => form.append('photos', file, file.name || `mobile-${Date.now()}.jpg`)); + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}/photos?platform=mobile`, { + method: 'POST', + body: form, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen.'); + serverFiles = data.files || serverFiles; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + selected = []; + render(); + uploadPanel.classList.add('hidden'); + successPanel.classList.remove('hidden'); + } catch (error) { + showError(error.message); + } finally { + progress.classList.add('hidden'); + uploadButton.disabled = false; + sourceButtons.forEach(button => { button.disabled = false; }); + } + } + + function returnToUploadAndOpen(opener) { + successPanel.classList.add('hidden'); + uploadPanel.classList.remove('hidden'); + window.setTimeout(opener, 50); + } + + cameraInput.addEventListener('change', () => { + addFiles(cameraInput.files || []); + cameraInput.value = ''; + }); + libraryInput.addEventListener('change', () => { + addFiles(libraryInput.files || []); + libraryInput.value = ''; + }); + + document.getElementById('open-camera-btn').addEventListener('click', openCamera); + document.getElementById('open-library-btn').addEventListener('click', openLibrary); + document.getElementById('add-camera-btn').addEventListener('click', openCamera); + document.getElementById('add-library-btn').addEventListener('click', openLibrary); + document.getElementById('success-camera-btn').addEventListener('click', () => returnToUploadAndOpen(openCamera)); + document.getElementById('success-library-btn').addEventListener('click', () => returnToUploadAndOpen(openLibrary)); + uploadButton.addEventListener('click', upload); + + loadSession(); +})(); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/style.css b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/style.css new file mode 100644 index 0000000..bbe91dc --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/public/style.css @@ -0,0 +1,4637 @@ +:root { + --bg: #f6f5f0; + --bg-card: #ffffff; + --bg-input: #ffffff; + --bg-hover: #eeedea; + --bg-page: #eae8e1; + --border: #e0ddd4; + --border-focus: #2c6e49; + --text: #2c2c2a; + --text-muted: #888880; + --text-secondary: #6b6b64; + --accent: #2c6e49; + --accent-hover: #245a3b; + --accent-light: rgba(44,110,73,0.08); + --orange: #c77b2a; + --orange-hover: #b06a1e; + --orange-light: rgba(199,123,42,0.1); + --success: #2c6e49; + --danger: #c0392b; + --warning: #c77b2a; + --radius: 12px; + --radius-sm: 8px; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.06); + --shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; +} + +/* --- Sidebar --- */ +.sidebar { + width: 230px; + background: var(--bg-card); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 120; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.sidebar-header { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 15px; + flex-shrink: 0; +} + +.logo-text { + font-weight: 700; + font-size: 17px; + color: var(--accent); + letter-spacing: -0.3px; +} + +.sidebar-nav { + flex: 1; + padding: 4px 0; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} +.sidebar-nav::-webkit-scrollbar { + display: none; +} + +.nav-group { + padding: 10px 10px 2px; +} + +.nav-group-label { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.7px; + padding: 0 8px 6px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: none; + background: none; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + text-align: left; +} + +.nav-item svg { flex-shrink: 0; opacity: 0.7; } +.nav-item span { flex: 1; } +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item:hover svg { opacity: 1; } +.nav-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 600; +} +.nav-item.active svg { opacity: 1; color: var(--accent); } + +.nav-badge { + font-size: 10px; + font-weight: 700; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9px; + padding: 0 5px; + background: var(--danger); + color: white; + line-height: 1; +} + +.sidebar-footer { + margin-top: auto; + padding: 10px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: var(--radius-sm); +} + +.sidebar-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} + +.sidebar-user-info { flex: 1; min-width: 0; } +.sidebar-user-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-user-role { + font-size: 11px; + color: var(--text-muted); + text-transform: capitalize; +} + +.sidebar-logout { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.sidebar-logout:hover { color: var(--danger); background: rgba(192,57,43,0.08); } + +.sidebar-footer .theme-toggle { + width: 100%; + justify-content: center; + border-radius: var(--radius-sm); + height: 32px; +} + +/* --- Main Wrapper --- */ +.main-wrapper { + flex: 1; + margin-left: 230px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 28px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + min-height: 56px; +} + +.topbar-title { + font-size: 18px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.topbar-actions { display: flex; gap: 8px; align-items: center; } + +.topbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-card); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} +.topbar-btn:hover { border-color: var(--accent); color: var(--accent); } +.topbar-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.topbar-btn.primary:hover { background: var(--accent-hover); } + +main { max-width: 960px; margin: 0 auto; padding: 24px 28px; width: 100%; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +/* Generator Grid */ +.generator-grid { display: grid; gap: 20px; align-items: start; } +@media (min-width: 600px) { .generator-grid { grid-template-columns: 1fr 240px; } } + +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-card); +} +.dropzone:hover, .dropzone.dragover { + border-color: var(--accent); + background: var(--accent-light); +} +.dropzone p { color: var(--text-muted); font-size: 14px; line-height: 1.6; } + +.photo-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 8px; margin-top: 12px; } +.photo-preview .thumb { position: relative; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden; box-shadow: var(--shadow-sm); } +.photo-preview .thumb img { width: 100%; height: 100%; object-fit: cover; } +.photo-preview .thumb .remove-photo { + position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; + border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; opacity: 0; transition: opacity 0.15s; +} +.photo-preview .thumb .edit-photo { + position: absolute; top: 4px; left: 4px; width: 24px; height: 24px; + border-radius: 50%; background: var(--accent); color: white; border: 2px solid white; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; transition: transform 0.15s, background 0.15s; + z-index: 2; box-shadow: 0 1px 4px rgba(0,0,0,0.3); +} +.photo-preview .thumb .edit-photo:hover { background: var(--accent-hover); transform: scale(1.15); } +.photo-preview .thumb:hover .remove-photo { opacity: 1; } + +.image-tools { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + +.config-section { display: flex; flex-direction: column; gap: 14px; } +.config-group label { + display: block; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); margin-bottom: 6px; +} +.button-group { display: flex; gap: 5px; flex-wrap: wrap; } + +.toggle-btn { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-secondary); padding: 6px 12px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; +} +.toggle-btn:hover { border-color: var(--accent); color: var(--accent); } +.toggle-btn.active { background: var(--accent); border-color: var(--accent); color: white; } + +.model-select, select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 7px 10px; border-radius: var(--radius-sm); + font-size: 13px; cursor: pointer; +} +.model-select:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.primary-btn { + background: var(--accent); color: white; border: none; + padding: 10px 20px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; + box-shadow: var(--shadow-sm); +} +.primary-btn:hover { background: var(--accent-hover); } +.primary-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.secondary-btn { + background: var(--bg-card); color: var(--text); border: 1.5px solid var(--border); + padding: 8px 16px; border-radius: var(--radius-sm); font-size: 13px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.secondary-btn:hover { background: var(--bg-hover); border-color: var(--text-muted); } + +.small-btn { + background: var(--bg-card); color: var(--text-secondary); border: 1.5px solid var(--border); + padding: 5px 10px; border-radius: var(--radius-sm); font-size: 12px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.small-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* Meta Section */ +.meta-section { + margin-top: 20px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.meta-section h2 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; } +.meta-grid-edit { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } +.meta-edit-item { display: flex; flex-direction: column; gap: 3px; } +.meta-item { display: flex; flex-direction: column; gap: 2px; } +.meta-label { font-size: 10px; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.6px; font-weight: 600; } +.meta-value { font-size: 14px; font-weight: 500; } +.meta-input { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 8px; border-radius: var(--radius-sm); font-size: 13px; width: 100%; +} +.meta-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.fee-info { + margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); + font-size: 13px; color: var(--text-muted); +} +.fee-info .fee-net { font-size: 16px; font-weight: 600; color: var(--success); } + +/* Variant tabs */ +.variant-tabs { display: flex; gap: 6px; margin: 16px 0 0; } +.variant-tab { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-muted); padding: 6px 14px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; +} +.variant-tab.active { background: var(--orange); border-color: var(--orange); color: white; } + +/* Results */ +.result-section { + margin-top: 16px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.result-section h2 { font-size: 15px; margin-bottom: 14px; } +.result-field { margin-bottom: 14px; } +.field-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; } +.field-header label { font-size: 12px; font-weight: 600; color: var(--text-muted); } + +.copy-btn { + background: none; border: 1.5px solid var(--border); color: var(--text-muted); + padding: 3px 8px; border-radius: 6px; font-size: 11px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.copy-btn:hover { border-color: var(--orange); color: var(--orange); } + +.result-input, .result-textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.result-input:focus, .result-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.result-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* Loading */ +.loading { text-align: center; padding: 48px; } +.spinner { + width: 36px; height: 36px; border: 3px solid var(--border); + border-top-color: var(--orange); border-radius: 50%; + margin: 0 auto 16px; animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.loading p { color: var(--text-muted); font-size: 14px; } + +/* History */ +.history-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.history-toolbar input, .history-toolbar select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.history-toolbar input { flex: 1; min-width: 150px; } +.history-toolbar input:focus, .history-toolbar select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +/* History batch bar */ +.history-batch-bar { + display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; + padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); +} +.history-select-all-wrap { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; font-weight: 500; } +.history-select-all-wrap input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.history-batch-bar span { font-size: 13px; color: var(--text-muted); } +.danger-btn { color: var(--danger) !important; border-color: var(--danger) !important; } +.danger-btn:hover { background: rgba(192,57,43,0.08) !important; } +.danger-btn:disabled { opacity: 0.4; } +.history-toolbar input[type="date"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12px; +} + +.history-list { display: flex; flex-direction: column; gap: 6px; } +.history-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; cursor: pointer; transition: all 0.15s; + box-shadow: var(--shadow-sm); +} +.history-item:hover { border-color: var(--accent); box-shadow: var(--shadow); } +.history-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.history-item .item-info { flex: 1; min-width: 0; } +.history-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.history-item .item-actions { display: flex; gap: 4px; align-items: center; } + +.status-badge { + font-size: 10px; padding: 3px 8px; border-radius: 4px; text-transform: uppercase; font-weight: 600; +} +.status-active { background: rgba(44,110,73,0.1); color: var(--success); } +.status-sold { background: rgba(192,57,43,0.1); color: var(--danger); } +.status-reserved { background: var(--orange-light); color: var(--warning); } + +.status-select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 3px 6px; border-radius: 4px; font-size: 11px; cursor: pointer; +} + +.history-item .delete-btn { + background: none; border: 1px solid transparent; color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.history-item .delete-btn:hover { color: var(--danger); border-color: var(--danger); } + +.history-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } + +.history-detail { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); } +.history-detail h3 { margin: 14px 0 6px; font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; } +.history-detail p, .history-detail .detail-value { font-size: 14px; line-height: 1.6; white-space: pre-wrap; } +.history-detail .detail-photos { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.history-detail .detail-photos img { width: 100px; height: 100px; object-fit: cover; border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); } + +/* Detail edit fields */ +.detail-edit-group { margin-bottom: 14px; } +.detail-edit-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 4px; } +.detail-edit-group input, .detail-edit-group textarea, .detail-edit-group select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.detail-edit-group input:focus, .detail-edit-group textarea:focus, .detail-edit-group select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); +} +.detail-edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 480px) { .detail-edit-row { grid-template-columns: 1fr; } } +.detail-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Templates */ +.templates-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.templates-header h2 { font-size: 16px; } +.template-list { display: flex; flex-direction: column; gap: 6px; } +.template-item { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 16px; + box-shadow: var(--shadow-sm); +} +.template-item .tpl-info { flex: 1; } +.template-item .tpl-name { font-size: 14px; font-weight: 500; } +.template-item .tpl-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.template-item .tpl-actions { display: flex; gap: 6px; } +.template-form { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-top: 16px; box-shadow: var(--shadow-sm); } +.template-form h3 { font-size: 15px; margin-bottom: 16px; } + +/* Fee calculator */ +.fee-results { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; } +.fee-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 14px 18px; + display: flex; justify-content: space-between; align-items: center; + box-shadow: var(--shadow-sm); +} +.fee-card .fee-platform { font-weight: 600; font-size: 14px; } +.fee-card .fee-breakdown { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.fee-card .fee-amounts { text-align: right; } +.fee-card .fee-net-amount { font-size: 18px; font-weight: 700; color: var(--success); } +.fee-card .fee-deducted { font-size: 12px; color: var(--text-muted); } + +/* Inventory */ +.inventory-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.inventory-header h2 { margin: 0; } +.inventory-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; } +.inv-stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; text-align: center; } +.inv-stat-value { font-size: 24px; font-weight: 700; color: var(--accent); } +.inv-stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.inventory-locations { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } +.inv-location-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } +.inv-location-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.06); } +.inv-loc-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.inv-loc-icon { color: var(--accent); flex-shrink: 0; } +.inv-loc-name { font-weight: 600; font-size: 15px; } +.inv-loc-stats { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text-muted); } +.inv-loc-count { font-weight: 500; color: var(--text); } +.inv-loc-sold { color: var(--orange); } +.inv-loc-reserved { color: var(--accent); } +.inv-loc-value { margin-top: 8px; font-size: 16px; font-weight: 600; color: var(--accent); } +.inv-unassigned { border-style: dashed; opacity: 0.8; } +.inv-unassigned .inv-loc-icon { color: var(--text-muted); } +.inventory-detail-actions { display: flex; gap: 8px; margin: 12px 0; } +.inv-item-check { margin-right: 10px; } + +/* Settings */ +.settings-form { max-width: 500px; } +.setting-group { margin-bottom: 18px; } +.setting-group > label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; } +.setting-group small { display: block; margin-top: 3px; color: var(--text-muted); font-size: 12px; } +.setting-group input, .setting-group select, .setting-group textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.setting-group input:focus, .setting-group select:focus, .setting-group textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.key-input-wrap { display: flex; gap: 6px; } +.key-input-wrap input { flex: 1; } +.toggle-visibility { + background: var(--bg-card); border: 1.5px solid var(--border); color: var(--text-muted); + padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; white-space: nowrap; + font-weight: 500; +} +.toggle-visibility:hover { border-color: var(--accent); color: var(--accent); } + +/* Toast */ +.toast { + position: fixed; bottom: 24px; right: 24px; + background: var(--bg-card); border: 1.5px solid var(--success); + color: var(--text); padding: 10px 18px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 500; transform: translateY(100px); opacity: 0; + transition: all 0.3s; z-index: 1000; box-shadow: var(--shadow); +} +.toast.show { transform: translateY(0); opacity: 1; } + +/* Publish tab */ +.publish-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.publish-header h2 { font-size: 16px; } +.publish-actions-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.publish-actions-bar span { font-size: 13px; color: var(--text-muted); font-weight: 500; } +.publish-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.publish-filters input, .publish-filters select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.publish-filters input { flex: 1; min-width: 150px; } +.publish-filters input:focus, .publish-filters select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.publish-list { display: flex; flex-direction: column; gap: 6px; } +.publish-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); +} +.publish-item:hover { border-color: var(--accent); } +.publish-item input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } +.publish-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.publish-item .item-info { flex: 1; min-width: 0; } +.publish-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.publish-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.publish-item .item-publish-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.publish-badges { display: flex; gap: 4px; margin-right: 8px; } +.publish-badge { + font-size: 9px; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; font-weight: 600; +} +.publish-badge.copied { background: var(--orange-light); color: var(--orange); } +.publish-badge.published { background: rgba(44,110,73,0.1); color: var(--success); } + +/* Modal */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 500; + display: flex; align-items: center; justify-content: center; padding: 24px; +} +.modal { + background: var(--bg-card); border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.15); + max-width: 560px; width: 100%; max-height: 85vh; overflow-y: auto; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); + position: sticky; top: 0; background: var(--bg-card); z-index: 1; +} +.modal-header h3 { font-size: 15px; } +.modal-close { + background: none; border: none; font-size: 22px; color: var(--text-muted); + cursor: pointer; padding: 0 4px; line-height: 1; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 20px; } + +.smart-copy-field { margin-bottom: 14px; } +.smart-copy-field .field-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } +.smart-copy-field label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); } +.smart-copy-field input, .smart-copy-field textarea { + width: 100%; background: var(--bg-page); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 13px; font-family: inherit; resize: vertical; +} +.smart-copy-field textarea { min-height: 80px; } + +.smart-copy-steps { + background: var(--accent-light); border-radius: var(--radius-sm); + padding: 14px 16px; margin-bottom: 16px; +} +.smart-copy-steps h4 { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--accent); } +.smart-copy-steps ol { padding-left: 18px; font-size: 13px; line-height: 1.8; color: var(--text-secondary); } + +.smart-copy-photos { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.smart-copy-photos img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; box-shadow: var(--shadow-sm); } + +.smart-copy-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Etsy connect */ +.etsy-connect-wrap { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.etsy-status { font-size: 12px; font-weight: 600; } +.etsy-status.connected { color: var(--success); } +.etsy-status.disconnected { color: var(--text-muted); } + +/* HTML Preview */ +.html-preview { + background: #fff; border: 1.5px solid var(--border); border-radius: var(--radius-sm); + padding: 20px; min-height: 80px; line-height: 1.6; font-size: 14px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.04); +} +.html-source { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; line-height: 1.5; white-space: pre-wrap; tab-size: 2; + background: #2c2c2a; color: #e8e8e0; border-radius: var(--radius-sm); + padding: 14px; +} + +/* Quill editor overrides */ +.ql-toolbar.ql-snow { + border: 1.5px solid var(--border) !important; + border-radius: var(--radius-sm) var(--radius-sm) 0 0 !important; + background: var(--bg-page) !important; + font-family: inherit !important; +} +.ql-container.ql-snow { + border: 1.5px solid var(--border) !important; + border-top: none !important; + border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important; + font-family: inherit !important; + font-size: 14px !important; + min-height: 120px; + background: var(--bg-input); +} +.ql-editor { min-height: 100px; line-height: 1.7; color: var(--text); } +.ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; } +.ql-snow .ql-stroke { stroke: var(--text-secondary) !important; } +.ql-snow .ql-fill { fill: var(--text-secondary) !important; } +.ql-snow .ql-picker-label { color: var(--text-secondary) !important; } +.ql-snow .ql-active .ql-stroke { stroke: var(--accent) !important; } +.ql-snow .ql-active .ql-fill { fill: var(--accent) !important; } +.ql-snow .ql-active { color: var(--accent) !important; } +.ql-snow .ql-picker-options { background: var(--bg-card) !important; border-color: var(--border) !important; border-radius: var(--radius-sm) !important; } +.ql-editor:focus { box-shadow: inset 0 0 0 1px var(--accent); } + +.hidden { display: none !important; } +.empty-state { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; } + +/* Category Picker */ +.cat-picker-wrap { position: relative; } +.cat-picker-wrap .cat-search { width: 100%; } +.cat-dropdown { + display: none; position: absolute; top: 100%; left: 0; right: 0; + background: var(--bg-card); border: 1px solid var(--border); + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + max-height: 260px; overflow-y: auto; z-index: 20; + scrollbar-width: thin; +} +.cat-empty { padding: 14px; text-align: center; color: var(--text-muted); font-size: 13px; } +.cat-result-item { + padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--bg-hover); +} +.cat-result-item:hover { background: var(--accent-light); } +.cat-result-name { font-weight: 600; font-size: 13px; color: var(--text); } +.cat-result-path { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.cat-group-hd { + padding: 7px 10px; font-size: 13px; font-weight: 700; color: var(--accent); + cursor: pointer; background: var(--bg-hover); border-bottom: 1px solid var(--border); +} +.cat-group-hd:hover { background: var(--bg-page); } +.cat-group-hd::before { content: '▸ '; font-size: 10px; color: var(--text-muted); } +.cat-group-hd.expanded::before { content: '▾ '; } +.cat-sub-hd { + padding: 5px 10px 5px 18px; font-size: 12px; font-weight: 600; + color: var(--text-secondary); background: var(--bg); +} +.cat-leaf { + padding: 5px 10px 5px 28px; font-size: 12px; cursor: pointer; color: var(--text); + border-bottom: 1px solid rgba(0,0,0,0.03); +} +.cat-leaf:hover { background: var(--accent-light); color: var(--accent); } +.cat-selected-display { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; background: var(--accent-light); + border: 1px solid var(--accent); font-size: 13px; + color: var(--accent); font-weight: 500; +} +.cat-sel-clear { + margin-left: auto; cursor: pointer; font-size: 16px; line-height: 1; + color: var(--text-muted); background: none; border: none; padding: 0 2px; +} +.cat-sel-clear:hover { color: var(--danger); } + +/* SKU Badge */ +.sku-badge { + display: inline-block; font-size: 10px; font-weight: 700; + background: var(--accent-light); color: var(--accent); + padding: 1px 5px; margin-right: 6px; letter-spacing: 0.3px; +} + +/* Publish badges in history */ +.item-pub-badges { display: flex; gap: 4px; margin-top: 3px; flex-wrap: wrap; } +.publish-badge { + font-size: 9px; font-weight: 600; padding: 1px 6px; + text-transform: uppercase; letter-spacing: 0.3px; +} +.pub-published { background: #e8f5e9; color: var(--accent); } +.pub-copied { background: var(--orange-light); color: var(--orange); } +.pub-pending { background: #f0f0f0; color: #999; } +.pub-failed { background: #fce4e4; color: #c62828; } + +/* Detail header with QR */ +.detail-header-row { display: flex; gap: 16px; align-items: flex-start; } +.detail-header-row .detail-photos { flex: 1; } +.detail-qr-wrap { + flex-shrink: 0; display: flex; flex-direction: column; + align-items: center; gap: 4px; padding: 8px; + background: var(--bg); border: 1px solid var(--border); +} +.detail-sku-big { font-size: 14px; font-weight: 800; color: var(--accent); letter-spacing: 0.5px; } + +/* Dark Mode */ +[data-theme="dark"] { + --bg: #1a1a1e; + --bg-card: #242428; + --bg-input: #2c2c30; + --bg-hover: #32323a; + --bg-page: #18181c; + --border: #3a3a42; + --border-focus: #3d9e6a; + --text: #e4e4e0; + --text-muted: #8a8a90; + --text-secondary: #b0b0b4; + --accent: #3d9e6a; + --accent-hover: #4db87a; + --accent-light: rgba(61,158,106,0.12); + --orange: #e09040; + --orange-hover: #d08030; + --orange-light: rgba(224,144,64,0.12); + --success: #3d9e6a; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.2); + --shadow: 0 2px 8px rgba(0,0,0,0.3); +} +[data-theme="dark"] .sidebar { background: #1e1e22; border-color: var(--border); } +[data-theme="dark"] .html-preview { background: var(--bg-input); } +[data-theme="dark"] .ql-editor { color: var(--text); } +[data-theme="dark"] .ql-container.ql-snow { background: var(--bg-input) !important; } +[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--bg-hover) !important; } + +.theme-toggle { + background: none; border: 1px solid var(--border); color: var(--text-muted); + height: 32px; border-radius: var(--radius-sm); cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 14px; transition: all 0.15s; flex-shrink: 0; + padding: 0 8px; gap: 6px; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } + +/* Dashboard */ +.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.stat-card .stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); font-weight: 600; } +.stat-card .stat-value { font-size: 28px; font-weight: 800; color: var(--text); margin-top: 4px; } +.stat-card .stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.orange .stat-value { color: var(--orange); } + +.chart-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); + margin-bottom: 16px; +} +.chart-card h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.chart-bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.chart-bar-label { font-size: 12px; min-width: 100px; color: var(--text-secondary); } +.chart-bar-track { flex: 1; height: 22px; background: var(--bg-hover); border-radius: 4px; overflow: hidden; } +.chart-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; display: flex; align-items: center; padding-left: 8px; font-size: 11px; font-weight: 600; color: #fff; } +.chart-bar-fill.green { background: var(--accent); } +.chart-bar-fill.orange { background: var(--orange); } +.chart-bar-fill.blue { background: #4a90d9; } + +/* Photo Editor */ +.photo-editor-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 600; + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.photo-editor { + background: var(--bg-card); border-radius: var(--radius); max-width: 700px; + width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.3); +} +.photo-editor-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 20px; border-bottom: 1px solid var(--border); +} +.photo-editor-header h3 { font-size: 15px; } +.photo-editor-canvas-wrap { + display: flex; align-items: center; justify-content: center; + padding: 20px; background: var(--bg-page); min-height: 300px; position: relative; +} +.photo-editor-canvas-wrap canvas { max-width: 100%; max-height: 400px; border-radius: 4px; box-shadow: var(--shadow); } +.photo-editor-canvas-wrap .crop-overlay { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + cursor: crosshair; border-radius: 4px; +} +.pe-crop-active #pe-crop-toggle { background: var(--accent); color: white; } +.photo-editor-controls { + padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; + border-top: 1px solid var(--border); +} +.photo-editor-controls .editor-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.photo-editor-controls label { font-size: 12px; font-weight: 600; min-width: 80px; color: var(--text-muted); } +.photo-editor-controls input[type="range"] { flex: 1; accent-color: var(--accent); } +.photo-editor-controls .range-val { font-size: 12px; color: var(--text-secondary); min-width: 35px; text-align: right; } +.photo-editor-actions { + padding: 14px 20px; border-top: 1px solid var(--border); + display: flex; gap: 8px; justify-content: flex-end; +} + +/* Drag handle for sortable items */ +.drag-handle { + cursor: grab; color: var(--text-muted); font-size: 16px; padding: 0 4px; + user-select: none; touch-action: none; +} +.drag-handle:active { cursor: grabbing; } +.photo-preview .thumb.dragging { opacity: 0.4; } +.photo-preview .thumb.drag-over { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Scheduling */ +.schedule-wrap { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.schedule-wrap input[type="datetime-local"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 10px; border-radius: var(--radius-sm); font-size: 13px; +} + +/* Backup section */ +.backup-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); } +.backup-section h3 { font-size: 14px; margin-bottom: 12px; } +.backup-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* --- Role Badge (used in admin) --- */ +.role-badge { + display: inline-block; padding: 2px 10px; border-radius: 12px; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 6px; +} +.role-badge.admin { background: #ffeaa7; color: #856404; } +.role-badge.editor { background: #dfe6e9; color: #636e72; } + +/* --- Admin Panel --- */ +.admin-wrap { max-width: 900px; } +.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } +.admin-header h2 { font-size: 18px; } +.admin-tabs { display: flex; gap: 2px; margin-bottom: 16px; } +.admin-tab { + padding: 8px 16px; background: var(--bg); border: 1px solid var(--border); + border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; font-weight: 500; + color: var(--text-muted); transition: all 0.15s; border-bottom: none; +} +.admin-tab.active { background: var(--bg-card); color: var(--text); font-weight: 600; border-bottom: 2px solid var(--accent); } +.admin-panel { display: none; } +.admin-panel.active { display: block; } + +/* User list */ +.users-list { display: flex; flex-direction: column; gap: 8px; } +.user-card { + display: flex; align-items: center; gap: 14px; padding: 14px 18px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; + transition: box-shadow 0.15s; +} +.user-card:hover { box-shadow: var(--shadow); } +.user-card-avatar { + width: 40px; height: 40px; border-radius: 50%; + font-size: 14px; font-weight: 700; color: white; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.user-card-info { flex: 1; min-width: 0; } +.user-card-info .name { font-weight: 600; font-size: 14px; color: var(--text); } +.user-card-info .email { font-size: 12px; color: var(--text-muted); } +.user-card-info .meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; } +.user-card-actions { display: flex; gap: 6px; align-items: center; } +.user-card .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.user-card .status-dot.online { background: #27ae60; } +.user-card .status-dot.offline { background: #bbb; } +.user-card .status-dot.inactive { background: var(--danger); } + +/* Sessions list */ +.sessions-list .session-row { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; + margin-bottom: 6px; font-size: 13px; +} +.session-row .session-info { flex: 1; } +.session-row .session-info .session-ua { font-size: 11px; color: var(--text-muted); } +.session-row .session-info .session-ip { font-size: 11px; color: var(--text-muted); } +.session-row .session-meta { font-size: 11px; color: var(--text-muted); text-align: right; min-width: 100px; } + +/* Audit log */ +.audit-list { max-height: 600px; overflow-y: auto; } +.audit-row { + display: flex; gap: 10px; padding: 8px 12px; font-size: 12px; + border-bottom: 1px solid var(--border); align-items: center; +} +.audit-row:hover { background: var(--bg-hover); } +.audit-row .audit-time { min-width: 130px; color: var(--text-muted); font-size: 11px; } +.audit-row .audit-user { min-width: 120px; font-weight: 500; } +.audit-row .audit-action { flex: 1; } +.audit-action-badge { + display: inline-block; padding: 1px 8px; border-radius: 4px; + font-size: 10px; font-weight: 600; text-transform: uppercase; +} +.audit-action-badge.login { background: #e3f2fd; color: #1565c0; } +.audit-action-badge.user { background: #fce4ec; color: #c62828; } +.audit-action-badge.listing { background: #e8f5e9; color: #2e7d32; } +.audit-action-badge.session { background: #fff3e0; color: #e65100; } + +/* Modals (reusable) */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 300; + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.modal-overlay.hidden { display: none; } +.modal { + background: var(--bg-card); border-radius: 12px; width: 100%; + box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); +} +.modal-header h3 { font-size: 15px; } +.modal-body { padding: 20px; } +.modal-footer { + padding: 14px 20px; border-top: 1px solid var(--border); + display: flex; gap: 8px; justify-content: flex-end; +} +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 5px; } +.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; background: var(--bg-input); } +.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(44,110,73,0.1); } + +/* SMTP Config */ +.smtp-config-form { max-width: 500px; } +.smtp-status-bar { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; margin-bottom: 18px; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; +} +.smtp-status-dot { + width: 10px; height: 10px; border-radius: 50%; + flex-shrink: 0; display: inline-block; +} +.smtp-status-dot.configured { background: var(--success); } +.smtp-status-dot.not-configured { background: var(--text-muted); } +.smtp-actions { display: flex; gap: 8px; margin-top: 16px; } + +/* System Info Grid */ +.system-info-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.system-info-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.system-info-card .sys-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); font-weight: 600; +} +.system-info-card .sys-value { + font-size: 22px; font-weight: 700; color: var(--text); margin-top: 4px; +} +.system-info-card .sys-value.accent { color: var(--accent); } + +/* Password Status Badge */ +.pw-status-badge { + display: inline-block; padding: 3px 10px; border-radius: 4px; + font-size: 11px; font-weight: 600; +} +.pw-status-badge.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-status-badge.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Login History in Edit Modal */ +.login-history-list { + max-height: 200px; overflow-y: auto; margin-top: 8px; + border: 1px solid var(--border); border-radius: var(--radius-sm); +} +.login-history-item { + display: flex; gap: 8px; align-items: center; + padding: 6px 10px; font-size: 11px; + border-bottom: 1px solid var(--border); +} +.login-history-item:last-child { border-bottom: none; } +.login-history-item .lh-time { min-width: 110px; color: var(--text-muted); } +.login-history-item .lh-ip { color: var(--text-secondary); min-width: 90px; } +.login-history-item .lh-status { font-weight: 600; } +.login-history-item .lh-status.success { color: var(--success); } +.login-history-item .lh-status.failed { color: var(--danger); } + +/* User card has_password indicator */ +.user-card .pw-indicator { + font-size: 10px; padding: 1px 6px; border-radius: 3px; + font-weight: 600; display: inline-block; margin-left: 6px; +} +.pw-indicator.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-indicator.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Pagination */ +.pagination-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); margin-bottom: 8px; box-shadow: var(--shadow-sm); + flex-wrap: wrap; gap: 8px; +} +#history-pagination-bottom, #publish-pagination-bottom { + margin-top: 8px; margin-bottom: 0; +} +.pagination-sizes { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); } +.page-size-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-size-btn:hover { border-color: var(--accent); color: var(--accent); } +.page-size-btn.active { background: var(--accent); border-color: var(--accent); color: white; } +.pagination-nav { display: flex; align-items: center; gap: 8px; } +.page-nav-btn { + background: var(--bg-card); border: 1px solid var(--border); color: var(--text); + padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-nav-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.page-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.page-info { font-size: 12px; color: var(--text-muted); font-weight: 500; min-width: 100px; text-align: center; } + +/* Trash / Papierkorb */ +.trash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.trash-header h2 { font-size: 16px; } +.trash-actions-bar { display: flex; align-items: center; gap: 12px; } +.trash-info-text { font-size: 12px; color: var(--text-muted); } +.trash-list { display: flex; flex-direction: column; gap: 6px; } +.trash-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); + opacity: 0.7; +} +.trash-item:hover { opacity: 1; border-color: var(--border); } +.trash-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); filter: grayscale(40%); } +.trash-item .item-info { flex: 1; min-width: 0; } +.trash-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trash-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.trash-item .trash-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.trash-item .restore-btn { + background: none; border: 1px solid var(--accent); color: var(--accent); + padding: 3px 10px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .restore-btn:hover { background: var(--accent); color: white; } +.trash-item .perm-delete-btn { + background: none; border: 1px solid var(--danger); color: var(--danger); + padding: 3px 8px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .perm-delete-btn:hover { background: var(--danger); color: white; } + +/* Trash badge on tab */ +.trash-badge { + display: inline-flex; align-items: center; justify-content: center; + background: var(--danger); color: white; + font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; + border-radius: 8px; padding: 0 4px; margin-left: 4px; + line-height: 1; +} + +/* Invite method radios */ +.invite-method-radios { display: flex; flex-direction: column; gap: 8px; } +.radio-label { + display: flex; align-items: center; gap: 8px; cursor: pointer; + font-size: 13px; color: var(--text); +} +.radio-label input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; } +.radio-label small { color: var(--text-muted); } + +/* Duplicate & bulk status button */ +.duplicate-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.duplicate-btn:hover { color: var(--accent); border-color: var(--accent); } + +/* Bulk status modal */ +.bulk-status-options { display: flex; gap: 8px; margin-top: 12px; } + +/* Sidebar mobile toggle */ +.sidebar-toggle { + display: none; + background: none; border: none; color: var(--text); + cursor: pointer; padding: 4px; +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform 0.25s ease; + box-shadow: none; + } + .sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0,0,0,0.15); + } + .main-wrapper { margin-left: 0; } + .sidebar-toggle { display: flex; } + .sidebar-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.3); + z-index: 110; display: none; + } + .sidebar-overlay.show { display: block; } +} + +@media (max-width: 480px) { + main { padding: 14px; } + .dashboard-grid { grid-template-columns: 1fr 1fr; } + .admin-header { flex-direction: column; gap: 10px; align-items: flex-start; } + .user-card { flex-direction: column; text-align: center; } + .system-info-grid { grid-template-columns: 1fr 1fr; } + .admin-tabs { flex-wrap: wrap; } + .pagination-bar { flex-direction: column; } + .topbar-actions { gap: 4px; } + .topbar-btn span { display: none; } +} + +/* ========================================================================== + Vendoo UI Foundation 2026 + Freigegebene Designrichtung: Editorial Commerce + Studio Flow + ========================================================================== */ +:root { + --vd-canvas: #f7f5f1; + --vd-surface: #fffdf9; + --vd-surface-raised: #ffffff; + --vd-surface-muted: #f1efea; + --vd-surface-quiet: #ebe8e1; + --vd-line: #e2ded6; + --vd-line-strong: #cbc5ba; + --vd-ink: #171714; + --vd-ink-secondary: #5f5b54; + --vd-ink-muted: #8c877e; + --vd-brand: #e33a16; + --vd-brand-strong: #c92f10; + --vd-brand-soft: rgba(227, 58, 22, 0.08); + --vd-success: #2f7d50; + --vd-success-soft: #e7f2ea; + --vd-warning: #b96a23; + --vd-warning-soft: #fbefe3; + --vd-danger: #c44234; + --vd-danger-soft: #f9e8e5; + --vd-info: #3975ba; + --vd-info-soft: #e8f0f9; + --vd-focus: rgba(227, 58, 22, 0.18); + --vd-shadow-float: 0 18px 48px rgba(35, 30, 24, 0.12); + --vd-shadow-soft: 0 8px 24px rgba(35, 30, 24, 0.06); + --vd-radius-xs: 5px; + --vd-radius-sm: 8px; + --vd-radius-md: 12px; + --vd-sidebar-width: 148px; + --vd-topbar-height: 60px; + + /* Legacy token bridge */ + --bg: var(--vd-canvas); + --bg-card: var(--vd-surface-raised); + --bg-input: var(--vd-surface-raised); + --bg-hover: var(--vd-surface-muted); + --bg-page: var(--vd-surface-quiet); + --border: var(--vd-line); + --border-focus: var(--vd-brand); + --text: var(--vd-ink); + --text-muted: var(--vd-ink-muted); + --text-secondary: var(--vd-ink-secondary); + --accent: var(--vd-brand); + --accent-hover: var(--vd-brand-strong); + --accent-light: var(--vd-brand-soft); + --orange: var(--vd-warning); + --orange-hover: var(--vd-warning); + --orange-light: var(--vd-warning-soft); + --success: var(--vd-success); + --danger: var(--vd-danger); + --warning: var(--vd-warning); + --radius: var(--vd-radius-md); + --radius-sm: var(--vd-radius-sm); + --shadow-sm: none; + --shadow: var(--vd-shadow-soft); +} + +[data-theme="dark"] { + --vd-canvas: #171714; + --vd-surface: #1d1d19; + --vd-surface-raised: #22221e; + --vd-surface-muted: #292923; + --vd-surface-quiet: #303029; + --vd-line: #37372f; + --vd-line-strong: #4a4940; + --vd-ink: #f2efe8; + --vd-ink-secondary: #c3beb4; + --vd-ink-muted: #8f8a80; + --vd-brand: #ff5a32; + --vd-brand-strong: #ff744f; + --vd-brand-soft: rgba(255, 90, 50, 0.12); + --vd-success: #75bd8c; + --vd-success-soft: #203629; + --vd-warning: #e2a35f; + --vd-warning-soft: #3b2e20; + --vd-danger: #ed7568; + --vd-danger-soft: #3b2523; + --vd-info: #78a9df; + --vd-info-soft: #202f40; + --vd-focus: rgba(255, 90, 50, 0.26); + --vd-shadow-float: 0 20px 56px rgba(0, 0, 0, 0.38); + --vd-shadow-soft: 0 8px 26px rgba(0, 0, 0, 0.2); +} + +html { background: var(--vd-canvas); } +body { + background: var(--vd-canvas); + color: var(--vd-ink); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.008em; +} +button, input, select, textarea { font: inherit; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { + outline: 3px solid var(--vd-focus); + outline-offset: 2px; +} + +/* App shell */ +.sidebar { + width: var(--vd-sidebar-width); + background: var(--vd-surface); + border-right: 1px solid var(--vd-line); + padding: 0; + overflow: hidden; +} +.sidebar-header { + height: var(--vd-topbar-height); + padding: 0 22px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--vd-line); +} +.sidebar-logo { gap: 9px; color: var(--vd-brand); } +.logo-mark { flex: 0 0 auto; } +.logo-text { + color: var(--vd-ink); + font-size: 18px; + font-weight: 750; + letter-spacing: -0.045em; +} +.sidebar-nav { padding: 14px 10px 8px; } +.nav-group { padding: 0; } +.nav-group-primary { display: flex; flex-direction: column; gap: 5px; } +.nav-group-tools { margin-top: 14px; display: flex; flex-direction: column; gap: 3px; } +.nav-divider { height: 1px; background: var(--vd-line); margin: 2px 10px 12px; } +.nav-item { + gap: 11px; + min-height: 42px; + padding: 0 12px; + border-radius: var(--vd-radius-sm); + color: var(--vd-ink-secondary); + font-size: 13px; + font-weight: 540; + position: relative; +} +.nav-item::before { + content: ""; + position: absolute; + left: -10px; + width: 2px; + height: 22px; + border-radius: 2px; + background: transparent; +} +.nav-item svg { opacity: .78; } +.nav-item:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.nav-item.active { + color: var(--vd-brand); + background: var(--vd-brand-soft); + font-weight: 650; +} +.nav-item.active::before { background: var(--vd-brand); } +.nav-item.active svg { color: var(--vd-brand); } +.nav-item-quiet { min-height: 36px; font-size: 12px; } +.nav-badge { margin-left: auto; background: var(--vd-danger); } +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface); + gap: 6px; +} +.sidebar-user { padding: 6px; gap: 8px; } +.sidebar-user-avatar, .topbar-avatar { + background: var(--vd-ink); + color: var(--vd-surface-raised); +} +.sidebar-user-info { display: none; } +.sidebar-logout { margin-left: auto; } +.sidebar-footer .theme-toggle { + height: 34px; + justify-content: flex-start; + padding: 0 9px; + gap: 9px; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 12px; +} +.sidebar-footer .theme-toggle:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } + +.main-wrapper { margin-left: var(--vd-sidebar-width); } +.topbar { + min-height: var(--vd-topbar-height); + height: var(--vd-topbar-height); + padding: 0 22px 0 26px; + background: color-mix(in srgb, var(--vd-surface) 94%, transparent); + border-bottom: 1px solid var(--vd-line); + backdrop-filter: blur(14px); + gap: 22px; +} +.topbar-context { display: flex; align-items: center; gap: 10px; min-width: 185px; } +.topbar-context-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + color: var(--vd-ink-secondary); +} +.topbar-heading { display: flex; align-items: baseline; gap: 9px; } +.topbar-title { font-size: 14px; font-weight: 700; letter-spacing: -0.025em; white-space: nowrap; } +.topbar-kicker { font-size: 11px; color: var(--vd-ink-muted); white-space: nowrap; } +.command-search { + width: min(470px, 42vw); + height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px 0 12px; + cursor: pointer; + text-align: left; +} +.command-search:hover { border-color: var(--vd-line-strong); color: var(--vd-ink-secondary); } +.command-search span { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.command-search kbd { + min-width: 34px; + padding: 2px 5px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-xs); + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: center; +} +.topbar-actions { margin-left: auto; gap: 7px; } +.topbar-btn { + height: 36px; + border-radius: var(--vd-radius-sm); + padding: 0 14px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); +} +.topbar-btn.primary { + background: var(--vd-brand); + border-color: var(--vd-brand); + box-shadow: 0 5px 14px var(--vd-brand-soft); +} +.topbar-btn.primary:hover { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); } +.topbar-icon-btn { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 0; + background: transparent; + color: var(--vd-ink-secondary); + border-radius: 50%; + position: relative; + cursor: pointer; +} +.topbar-icon-btn:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.topbar-dot { + position: absolute; + top: 6px; + right: 7px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vd-brand); + box-shadow: 0 0 0 2px var(--vd-surface); +} +.topbar-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 750; +} + +main { + max-width: none; + margin: 0; + padding: 20px 24px 28px; +} +.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; } +.page-heading h2 { font-size: 22px; line-height: 1.15; letter-spacing: -0.045em; } +.page-heading p { margin-top: 5px; color: var(--vd-ink-secondary); font-size: 12px; } +.eyebrow { + margin: 0 0 7px !important; + color: var(--vd-brand) !important; + font-size: 10px !important; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .12em; +} + +/* Studio Flow */ +.studio-flow-page { min-width: 0; } +.studio-summary { display: flex; align-items: center; gap: 14px; color: var(--vd-ink-muted); font-size: 11px; } +.studio-summary strong { color: var(--vd-ink); font-size: 13px; } +.studio-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 14px; + align-items: start; +} +.studio-workspace { min-width: 0; } +.flow-board-wrap { + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: var(--vd-line-strong) transparent; +} +.flow-board { + min-width: 1010px; + display: grid; + grid-template-columns: repeat(6, minmax(150px, 1fr)); + gap: 10px; +} +.flow-column { min-width: 0; } +.flow-column-header { + min-height: 34px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 4px 7px; + border-bottom: 1px solid var(--vd-line); + position: relative; +} +.flow-column:not(:last-child) .flow-column-header::after { + content: "→"; + position: absolute; + right: -10px; + top: 7px; + color: var(--vd-ink-muted); + font-size: 10px; + z-index: 2; +} +.flow-column-title { font-size: 11px; font-weight: 720; color: var(--vd-ink); } +.flow-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 700; +} +.flow-column-list { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; } +.flow-card { + width: 100%; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; + transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; +} +.flow-card:hover { + border-color: var(--vd-line-strong); + transform: translateY(-1px); + box-shadow: var(--vd-shadow-soft); +} +.flow-card.selected { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.flow-card-media { + aspect-ratio: 1.45; + overflow: hidden; + border-radius: 6px; + background: var(--vd-surface-muted); + margin-bottom: 7px; + position: relative; +} +.flow-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; } +.flow-card-placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--vd-ink-muted); } +.flow-card-title { + min-height: 30px; + font-size: 11px; + line-height: 1.32; + font-weight: 680; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.flow-card-sku { margin-top: 4px; color: var(--vd-ink-muted); font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; } +.flow-card-footer { margin-top: 7px; display: flex; align-items: center; gap: 5px; min-height: 20px; } +.flow-platform { font-size: 9px; font-weight: 780; text-transform: uppercase; letter-spacing: .025em; } +.flow-price { margin-left: auto; font-size: 10px; font-weight: 730; white-space: nowrap; } +.flow-state { + margin-left: auto; + min-height: 19px; + padding: 2px 7px; + border-radius: 10px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; + font-weight: 700; + white-space: nowrap; +} +.flow-state.success { background: var(--vd-success-soft); color: var(--vd-success); } +.flow-state.warning { background: var(--vd-warning-soft); color: var(--vd-warning); } +.flow-state.brand { background: var(--vd-brand-soft); color: var(--vd-brand); } +.flow-progress { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-brand) var(--progress), var(--vd-surface-muted) 0); position: relative; } +.flow-progress::after { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--vd-surface-raised); } +.flow-progress span { position: relative; z-index: 1; font-size: 8px; font-weight: 760; } +.flow-empty { + min-height: 116px; + border: 1px dashed var(--vd-line); + border-radius: var(--vd-radius-sm); + display: grid; + place-items: center; + color: var(--vd-ink-muted); + font-size: 10px; + text-align: center; + padding: 12px; +} + +.studio-bottom-grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 12px; margin-top: 14px; } +.work-panel { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + min-width: 0; +} +.work-panel-header { + min-height: 44px; + padding: 0 13px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--vd-line); +} +.work-panel-header > div { display: flex; align-items: center; gap: 8px; } +.work-panel-header h3 { font-size: 12px; letter-spacing: -0.02em; } +.panel-icon { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 800; } +.panel-icon-warning { color: var(--vd-danger); border: 1px solid var(--vd-danger); } +.panel-count { min-width: 22px; height: 20px; border-radius: 10px; background: var(--vd-surface-muted); display: grid; place-items: center; font-size: 10px; font-weight: 700; color: var(--vd-ink-muted); } +.text-action { border: 0; background: transparent; color: var(--vd-brand); font-size: 10px; cursor: pointer; } +.attention-list, .activity-list { padding: 5px 12px; } +.attention-item { + min-height: 47px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border-bottom: 1px solid var(--vd-line); +} +.attention-item:last-child, .activity-item:last-child { border-bottom: 0; } +.attention-thumb { width: 34px; height: 34px; border-radius: 5px; background: var(--vd-surface-muted); object-fit: cover; } +.attention-copy { min-width: 0; } +.attention-title { font-size: 10px; font-weight: 680; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-meta { margin-top: 2px; font-size: 9px; color: var(--vd-ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-action { height: 26px; padding: 0 9px; border: 1px solid var(--vd-line); border-radius: 6px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 9px; font-weight: 650; cursor: pointer; } +.attention-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.activity-item { min-height: 39px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid var(--vd-line); } +.activity-icon { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 9px; } +.activity-copy { font-size: 10px; color: var(--vd-ink-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-time { font-size: 9px; color: var(--vd-ink-muted); white-space: nowrap; } + +.studio-inspector { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + scrollbar-width: thin; +} +.inspector-empty { min-height: 520px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 28px; color: var(--vd-ink-muted); } +.inspector-empty-mark { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); margin-bottom: 14px; } +.inspector-empty h3 { color: var(--vd-ink); font-size: 14px; } +.inspector-empty p { margin-top: 7px; max-width: 210px; font-size: 11px; line-height: 1.5; } +.inspector-content { padding: 10px; } +.inspector-hero { aspect-ratio: 1.42; border-radius: 7px; overflow: hidden; background: var(--vd-surface-muted); } +.inspector-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } +.inspector-thumbs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin-top: 6px; } +.inspector-thumbs img { width: 100%; aspect-ratio: 1; border-radius: 5px; object-fit: cover; background: var(--vd-surface-muted); } +.inspector-title-row { padding: 14px 2px 10px; } +.inspector-title-row h3 { font-size: 15px; line-height: 1.35; letter-spacing: -0.025em; } +.inspector-sku { margin-top: 5px; color: var(--vd-ink-muted); font: 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.inspector-price-row { display: flex; align-items: center; gap: 8px; padding: 0 2px 12px; } +.inspector-platform { font-size: 10px; font-weight: 800; text-transform: uppercase; } +.inspector-price { margin-left: auto; font-size: 18px; font-weight: 760; letter-spacing: -0.04em; } +.inspector-card { margin-top: 8px; padding: 12px; border: 1px solid var(--vd-line); border-radius: 7px; } +.inspector-card h4 { margin-bottom: 9px; font-size: 10px; color: var(--vd-ink-secondary); } +.confidence-row { display: flex; align-items: center; gap: 10px; } +.confidence-ring { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-success) var(--confidence), var(--vd-surface-muted) 0); position: relative; } +.confidence-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface-raised); } +.confidence-ring span { position: relative; z-index: 1; font-size: 10px; font-weight: 780; } +.confidence-copy strong { display: block; color: var(--vd-success); font-size: 10px; } +.confidence-copy span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.4; } +.attribute-list { display: grid; grid-template-columns: 72px 1fr; gap: 6px 9px; font-size: 9px; } +.attribute-list dt { color: var(--vd-ink-muted); } +.attribute-list dd { color: var(--vd-ink-secondary); overflow-wrap: anywhere; } +.price-range { font-size: 15px; font-weight: 730; letter-spacing: -0.03em; } +.recommended-price { display: flex; justify-content: space-between; margin-top: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.recommended-price strong { color: var(--vd-ink); font-size: 12px; } +.inspector-actions { display: grid; gap: 6px; margin-top: 9px; } +.inspector-action { min-height: 36px; border: 1px solid var(--vd-line); border-radius: 7px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 11px; font-weight: 650; cursor: pointer; } +.inspector-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.inspector-action.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: white; } +.inspector-action.primary:hover { background: var(--vd-brand-strong); color: white; } + +/* Legacy component refinement */ +.dropzone, .meta-section, .result-section, .chart-card, .stat-card, .admin-card, .settings-card { + box-shadow: none; +} +input, select, textarea, .model-select, .result-input, .result-textarea, .meta-input { + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +input:focus, select:focus, textarea:focus, .model-select:focus, .result-input:focus, .result-textarea:focus, .meta-input:focus { + border-color: var(--vd-brand); + box-shadow: 0 0 0 3px var(--vd-focus); +} +.primary-btn { background: var(--vd-brand); box-shadow: none; } +.primary-btn:hover { background: var(--vd-brand-strong); } +.secondary-btn, .small-btn { background: var(--vd-surface-raised); border-color: var(--vd-line); } + +@media (max-width: 1180px) { + .studio-layout { grid-template-columns: minmax(0, 1fr); } + .studio-inspector { position: static; max-height: none; } + .inspector-empty { min-height: 180px; } + .inspector-content { display: grid; grid-template-columns: minmax(240px, .85fr) 1.15fr; gap: 12px; } + .inspector-content > * { min-width: 0; } +} + +@media (max-width: 860px) { + :root { --vd-sidebar-width: 148px; } + .command-search { width: min(320px, 38vw); } + .topbar-kicker { display: none; } + .studio-bottom-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .main-wrapper { margin-left: 0; } + .topbar { padding: 0 12px; gap: 8px; } + .topbar-context { min-width: 0; } + .topbar-context-icon { display: none; } + .command-search { flex: 1; width: auto; } + .command-search span { display: none; } + .command-search kbd { display: none; } + .topbar-icon-btn, .topbar-avatar { display: none; } + main { padding: 16px 14px 24px; } + .studio-heading { align-items: flex-start; flex-direction: column; } + .flow-board { min-width: 940px; } + .inspector-content { display: block; } +} + +@media (max-width: 520px) { + .topbar-btn.primary { width: 36px; padding: 0; justify-content: center; } + .topbar-btn.primary span { display: none; } + .topbar-title { max-width: 112px; overflow: hidden; text-overflow: ellipsis; } + .page-heading h2 { font-size: 20px; } + .studio-summary { flex-wrap: wrap; } +} + +/* ========================================================================== + Listings / Historie 2026 + ========================================================================== */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.listings-page { min-width: 0; } +.listings-heading { align-items: flex-start; } +.listings-heading-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} +.vd-button { + min-height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 11px; + font-weight: 650; + cursor: pointer; + transition: border-color .15s ease, color .15s ease, background .15s ease; + white-space: nowrap; +} +.vd-button:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.vd-button.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.vd-button.primary:hover:not(:disabled) { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); color: #fff; } +.vd-button.ghost { background: transparent; } +.vd-button.compact { min-height: 31px; padding: 0 10px; font-size: 10px; } +.vd-button.danger { color: var(--vd-danger); } +.vd-button:disabled { opacity: .38; cursor: not-allowed; } + +.history-export-wrap, +.history-columns-wrap { position: relative; } +.history-popover { + position: absolute; + z-index: 45; + top: calc(100% + 7px); + right: 0; + min-width: 190px; + padding: 7px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} +.history-export-menu button, +.history-row-menu button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vd-ink-secondary); + padding: 0 10px; + text-align: left; + font-size: 11px; + cursor: pointer; +} +.history-export-menu button:hover, +.history-row-menu button:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.history-row-menu button.danger { color: var(--vd-danger); } +.history-columns-panel { min-width: 208px; padding: 11px; } +.history-columns-panel strong { display: block; margin-bottom: 8px; font-size: 11px; } +.history-columns-panel label { + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + color: var(--vd-ink-secondary); + font-size: 11px; + cursor: pointer; +} +.history-columns-panel input { accent-color: var(--vd-brand); } + +.history-filterbar { + display: grid; + grid-template-columns: minmax(250px, 1.45fr) repeat(3, minmax(135px, .65fr)) auto auto; + gap: 9px; + align-items: end; + margin-bottom: 14px; +} +.history-search-field, +.history-filter-field, +.history-custom-dates label { + min-height: 52px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); +} +.history-search-field { + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + color: var(--vd-ink-muted); +} +.history-search-field:focus-within, +.history-filter-field:focus-within, +.history-custom-dates label:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.history-search-field input { + width: 100%; + min-width: 0; + height: 48px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 12px; +} +.history-search-field input::placeholder { color: var(--vd-ink-muted); } +.history-filter-field, +.history-custom-dates label { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 6px 11px; +} +.history-filter-field > span, +.history-custom-dates label > span { + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 650; +} +.history-filter-field select, +.history-custom-dates input { + width: 100%; + min-height: 23px; + padding: 0 20px 0 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 11px; + font-weight: 600; +} +.history-custom-dates { display: grid; grid-template-columns: repeat(2, 125px); gap: 7px; } +.history-filterbar > .vd-button { min-height: 52px; } + +.history-table-shell { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + overflow: visible; +} +.history-batch-bar { + min-height: 49px; + margin: 0; + padding: 8px 12px; + border: 0; + border-bottom: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md) var(--vd-radius-md) 0 0; + box-shadow: none; + background: var(--vd-surface-raised); + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; + overflow-x: auto; +} +.history-batch-bar.has-selection { background: color-mix(in srgb, var(--vd-brand-soft) 65%, var(--vd-surface-raised)); } +.history-select-all-wrap { + flex: 0 0 auto; + min-width: 112px; + min-height: 31px; + padding: 0 9px; + border-right: 1px solid var(--vd-line); + color: var(--vd-ink-secondary); +} +.history-select-all-wrap input, +.history-row-select { width: 15px; height: 15px; accent-color: var(--vd-brand); cursor: pointer; } +.history-batch-actions { display: flex; align-items: center; gap: 6px; } +.history-clear-selection { + flex: 0 0 auto; + margin-left: auto; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + cursor: pointer; +} +.history-clear-selection:hover { color: var(--vd-brand); } + +.history-table { + --history-platform: 104px; + --history-provider: 118px; + --history-price: 105px; + --history-status: 125px; + --history-location: 135px; + --history-published: 150px; + min-width: 1080px; + overflow: visible; +} +.history-table-shell { overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--vd-line-strong) transparent; } +.history-table-head, +.history-row { + display: grid; + grid-template-columns: + 34px + minmax(270px, 1.8fr) + var(--history-platform) + var(--history-provider) + var(--history-price) + var(--history-status) + var(--history-location) + var(--history-published) + 40px; + align-items: center; +} +.history-table-head { + min-height: 38px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 680; + letter-spacing: .018em; +} +.history-list { display: block; } +.history-row { + min-height: 70px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface-raised); + transition: background .15s ease; + position: relative; +} +.history-row:last-child { border-bottom: 0; } +.history-row:hover { background: color-mix(in srgb, var(--vd-surface-muted) 68%, transparent); } +.history-row.selected { background: color-mix(in srgb, var(--vd-brand-soft) 72%, var(--vd-surface-raised)); } +.history-cell { + min-width: 0; + padding: 8px 10px; + color: var(--vd-ink-secondary); + font-size: 10px; +} +.history-cell-select { padding-left: 14px; padding-right: 4px; } +.history-cell-article { display: flex; align-items: center; gap: 11px; } +.history-thumb { + width: 56px; + height: 48px; + flex: 0 0 auto; + border-radius: 6px; + object-fit: cover; + background: var(--vd-surface-muted); +} +.history-thumb-placeholder { display: grid; place-items: center; color: var(--vd-ink-muted); } +.history-article-copy { min-width: 0; } +.history-article-title { + display: block; + max-width: 100%; + border: 0; + background: transparent; + color: var(--vd-ink); + padding: 0; + font-size: 11px; + font-weight: 700; + line-height: 1.35; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.history-article-title:hover { color: var(--vd-brand); } +.history-article-sku { + margin-top: 4px; + color: var(--vd-ink-muted); + font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-cell strong { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-cell-sub, +.history-cell small { + display: block; + margin-top: 3px; + color: var(--vd-ink-muted); + font-size: 8.5px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-platform-name { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 760; } +.history-provider-mark { + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--vd-success-soft); + color: var(--vd-success); + font-size: 9px; + font-weight: 800; +} +.history-cell-provider { display: flex; align-items: center; gap: 7px; } +.history-cell-provider span:last-child { min-width: 0; } +.history-status-select { + max-width: 96px; + min-height: 23px; + border: 0; + border-radius: 5px; + padding: 0 20px 0 7px; + font-size: 9px; + font-weight: 720; + cursor: pointer; +} +.history-status-select.status-active { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-reserved { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-date { display: block; color: var(--vd-ink-secondary); font-size: 9px; white-space: nowrap; } +.history-publish-badges { display: flex; gap: 4px; margin-top: 4px; min-width: 0; } +.history-publish-badge { + max-width: 100%; + min-height: 18px; + padding: 2px 6px; + border-radius: 5px; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vd-ink-secondary); + background: var(--vd-surface-muted); + font-size: 8px; + font-weight: 680; + white-space: nowrap; +} +.history-publish-badge span { color: var(--vd-ink); font-weight: 780; } +.history-publish-badge.published { background: var(--vd-success-soft); color: var(--vd-success); } +.history-publish-badge.copied { background: var(--vd-info-soft); color: var(--vd-info); } +.history-publish-badge.pending { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-badge.failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.history-publish-empty { color: var(--vd-ink-muted); font-size: 8.5px; white-space: nowrap; } +.history-cell-menu { position: relative; padding-left: 4px; padding-right: 8px; } +.history-row-menu-button { + width: 30px; + height: 30px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font-size: 10px; + letter-spacing: 1px; + cursor: pointer; +} +.history-row-menu-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-row-menu { + position: absolute; + z-index: 35; + top: 31px; + right: 8px; + width: 145px; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} + +.history-hide-platform { --history-platform: 0px; } +.history-hide-provider { --history-provider: 0px; } +.history-hide-price { --history-price: 0px; } +.history-hide-status { --history-status: 0px; } +.history-hide-location { --history-location: 0px; } +.history-hide-published { --history-published: 0px; } +.history-hide-platform [data-column="platform"], +.history-hide-provider [data-column="provider"], +.history-hide-price [data-column="price"], +.history-hide-status [data-column="status"], +.history-hide-location [data-column="location"], +.history-hide-published [data-column="published"] { display: none; padding: 0; } + +.history-table-footer { + min-height: 55px; + padding: 9px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--vd-line); +} +.history-page-size { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 10px; } +.history-page-size select { + min-height: 30px; + padding: 0 25px 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + font-size: 10px; +} +.history-pagination-nav { display: flex; align-items: center; gap: 5px; } +.history-page-buttons { display: flex; align-items: center; gap: 4px; } +.history-page-button, +.history-pagination-nav .page-nav-btn { + min-width: 31px; + height: 31px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 10px; + cursor: pointer; +} +.history-page-button:hover:not(.active), +.history-pagination-nav .page-nav-btn:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-page-button.active { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.history-pagination-nav .page-nav-btn:disabled { opacity: .3; cursor: not-allowed; } +.history-page-gap { width: 20px; text-align: center; color: var(--vd-ink-muted); font-size: 10px; } +.history-empty-state { + min-height: 360px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 36px; +} +.history-empty-icon { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-muted); } +.history-empty-state h3 { margin-top: 13px; font-size: 14px; } +.history-empty-state p { margin: 5px 0 14px; color: var(--vd-ink-muted); font-size: 11px; } + +/* Detail stays functional while receiving the new surface language. */ +.listings-page .history-detail { + max-width: 1180px; + margin: 0 auto; + border-color: var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + box-shadow: none; +} + +@media (max-width: 1180px) { + .history-filterbar { grid-template-columns: minmax(230px, 1.3fr) repeat(3, minmax(125px, .7fr)); } + .history-filterbar > .vd-button { grid-column: auto; } + .history-custom-dates { grid-column: 1 / -1; justify-content: start; } + .history-table { min-width: 980px; --history-provider: 0px; --history-location: 0px; } + .history-table [data-column="provider"], + .history-table [data-column="location"] { display: none; } +} + +@media (max-width: 860px) { + .listings-heading { flex-direction: column; } + .listings-heading-actions { width: 100%; } + .history-filterbar { grid-template-columns: 1fr 1fr; } + .history-search-field { grid-column: 1 / -1; } + .history-filterbar > .vd-button { min-height: 42px; } + .history-batch-bar { align-items: flex-start; } + .history-batch-actions { min-width: max-content; } +} + +@media (max-width: 520px) { + .listings-heading-actions { overflow-x: auto; padding-bottom: 2px; } + .history-filterbar { grid-template-columns: 1fr; } + .history-search-field, + .history-filter-field { min-height: 48px; } + .history-search-field { grid-column: auto; } + .history-custom-dates { grid-template-columns: 1fr 1fr; } + .history-table-footer { align-items: flex-start; flex-direction: column; } + .history-pagination-nav { width: 100%; justify-content: space-between; } + .history-page-buttons { overflow-x: auto; } +} + +/* --- Slice 03: Studio media fit + in-app Print Studio --- */ +.flow-card-media, +.inspector-hero, +.attention-thumb { + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.flow-card-media { + display: grid; + place-items: center; + padding: 7px; +} +.flow-card-media img, +.inspector-hero img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} +.inspector-hero { + display: grid; + place-items: center; + padding: 12px; +} +.attention-thumb { + object-fit: contain; + object-position: center; + padding: 3px; +} + +body.print-studio-open { overflow: hidden; } +.print-studio { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + padding: 22px; +} +.print-studio-backdrop { + position: absolute; + inset: 0; + background: rgba(22, 22, 19, 0.56); + backdrop-filter: blur(9px); +} +.print-studio-shell { + position: relative; + width: min(1380px, 96vw); + height: min(900px, 94vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 18px; + background: var(--vd-surface-raised); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); +} +.print-studio-header, +.print-studio-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 17px 20px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +.print-studio-header { border-bottom: 1px solid var(--vd-line); } +.print-studio-footer { border-top: 1px solid var(--vd-line); justify-content: flex-end; } +.print-studio-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-brand); + font-size: 9px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.print-studio-header h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; } +.print-studio-close { + width: 36px; + height: 36px; + border: 1px solid var(--vd-line); + border-radius: 50%; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 23px; + line-height: 1; + cursor: pointer; +} +.print-studio-close:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.print-studio-layout { + min-height: 0; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); +} +.print-studio-controls { + padding: 20px; + overflow-y: auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.print-control-group { + display: flex; + align-items: center; + gap: 11px; + padding: 13px; + margin-bottom: 20px; + border: 1px solid var(--vd-line); + border-radius: 11px; + background: var(--vd-surface-raised); +} +.print-control-group > div { min-width: 0; display: grid; gap: 2px; } +.print-control-group strong { font-size: 13px; } +.print-control-group span:not(.print-summary-icon) { color: var(--vd-ink-muted); font-size: 10px; } +.print-summary-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 9px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 18px; + font-weight: 800; +} +.print-control-field { display: grid; gap: 7px; margin-bottom: 16px; } +.print-control-field > span, +.print-control-check > span { color: var(--vd-ink-secondary); font-size: 10px; font-weight: 700; } +.print-control-field select, +.print-control-field input { + width: 100%; + min-height: 40px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + padding: 0 11px; + font: inherit; + font-size: 11px; +} +.print-control-field select:focus, +.print-control-field input:focus { outline: 2px solid var(--vd-brand-soft); border-color: var(--vd-brand); } +.print-control-check { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 0; + cursor: pointer; +} +.print-control-check input { width: 16px; height: 16px; accent-color: var(--vd-brand); } +.print-hint { + display: grid; + gap: 5px; + margin-top: 24px; + padding: 13px; + border-left: 3px solid var(--vd-brand); + background: var(--vd-brand-soft); +} +.print-hint strong { font-size: 11px; } +.print-hint span { color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.5; } +.print-preview-pane { + min-width: 0; + overflow: auto; + padding: 0 24px 36px; + background: + linear-gradient(90deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + linear-gradient(0deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + var(--vd-surface-muted); + background-size: 24px 24px; +} +.print-preview-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 -24px 24px; + padding: 12px 24px; + border-bottom: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-muted) 90%, transparent); + backdrop-filter: blur(10px); + color: var(--vd-ink-muted); + font-size: 10px; +} +.print-preview-toolbar span:first-child { color: var(--vd-ink); font-weight: 700; } +.print-preview-pages { display: grid; justify-items: center; gap: 28px; } +.print-page { + width: 794px; + min-height: 1123px; + padding: 38px; + overflow: hidden; + background: #fff; + color: #1f1f1c; + box-shadow: 0 14px 40px rgba(31, 31, 28, .18); +} +.print-label-grid { display: grid; width: 100%; height: 100%; align-content: start; } +.standard-labels .print-label-grid { grid-template-columns: repeat(2, 340px); grid-auto-rows: 177px; gap: 23px 34px; } +.compact-labels .print-label-grid { grid-template-columns: repeat(3, 217px); grid-auto-rows: 101px; gap: 8px 18px; } +.print-label-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 15px; + overflow: hidden; + border: 1px solid #d8d5ce; + background: #fff; + padding: 15px 16px 15px 21px; +} +.print-label-accent { position: absolute; inset: 0 auto 0 0; width: 5px; background: #e33a16; } +.print-label-qr img { display: block; width: 106px; height: 106px; } +.print-label-copy { min-width: 0; display: grid; align-content: center; gap: 4px; } +.print-label-brand { color: #e33a16; font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.print-label-sku { font-size: 21px; font-weight: 850; letter-spacing: -.035em; } +.print-label-title { min-height: 31px; overflow: hidden; font-size: 11px; font-weight: 650; line-height: 1.35; } +.print-label-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #6d6961; font-size: 9px; } +.print-label-meta strong { color: #1f1f1c; font-size: 12px; } +.compact-labels .print-label-card { gap: 7px; padding: 7px 8px 7px 11px; } +.compact-labels .print-label-accent { width: 3px; } +.compact-labels .print-label-qr img { width: 62px; height: 62px; } +.compact-labels .print-label-brand { font-size: 6px; } +.compact-labels .print-label-sku { font-size: 12px; } +.compact-labels .print-label-title { min-height: 20px; font-size: 7px; } +.compact-labels .print-label-meta { font-size: 6px; } +.compact-labels .print-label-meta strong { font-size: 8px; } +.print-datasheet { min-height: 100%; display: grid; align-content: start; gap: 24px; } +.print-datasheet-header { display: flex; justify-content: space-between; gap: 30px; padding-bottom: 22px; border-bottom: 4px solid #e33a16; } +.print-datasheet-brand { display: block; margin-bottom: 7px; color: #e33a16; font-size: 10px; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; } +.print-datasheet h1 { max-width: 570px; margin: 0; font-size: 29px; line-height: 1.15; letter-spacing: -.04em; } +.print-datasheet-sku { margin-top: 9px; color: #5f5b54; font-size: 16px; font-weight: 750; } +.print-datasheet-qr { width: 118px; height: 118px; } +.print-datasheet-photos { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.print-datasheet-photos > div { aspect-ratio: 1; display: grid; place-items: center; border: 1px solid #dfdcd5; background: #f5f3ee; padding: 8px; } +.print-datasheet-photos img { width: 100%; height: 100%; object-fit: contain; } +.print-datasheet-description { padding: 17px 19px; border-left: 4px solid #e33a16; background: #f5f3ee; } +.print-datasheet-description h2 { margin: 0 0 7px; font-size: 13px; } +.print-datasheet-description p { margin: 0; color: #45423d; font-size: 11px; line-height: 1.65; } +.print-datasheet-meta { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid #dfdcd5; } +.print-datasheet-meta > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; padding: 11px 0; border-bottom: 1px solid #dfdcd5; } +.print-datasheet-meta > div:nth-child(odd) { padding-right: 20px; } +.print-datasheet-meta > div:nth-child(even) { padding-left: 20px; border-left: 1px solid #dfdcd5; } +.print-datasheet-meta span { color: #77726a; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } +.print-datasheet-meta strong { font-size: 11px; } + +@media (max-width: 1050px) { + .print-studio { padding: 10px; } + .print-studio-shell { width: 100%; height: 100%; border-radius: 12px; } + .print-studio-layout { grid-template-columns: 220px minmax(0, 1fr); } + .print-page { transform: scale(.78); transform-origin: top center; margin-bottom: -247px; } +} + +@media (max-width: 720px) { + .print-studio-layout { grid-template-columns: 1fr; } + .print-studio-controls { border-right: 0; border-bottom: 1px solid var(--vd-line); max-height: 220px; } + .print-preview-pane { min-height: 0; } + .print-page { transform: scale(.55); margin-bottom: -505px; } +} + +@media print { + @page { size: A4 portrait; margin: 0; } + body.print-studio-active { display: block !important; background: #fff !important; } + body.print-studio-active > *:not(#print-studio) { display: none !important; } + body.print-studio-active #print-studio { + position: static !important; + display: block !important; + padding: 0 !important; + background: #fff !important; + } + body.print-studio-active .print-studio-backdrop, + body.print-studio-active .print-studio-header, + body.print-studio-active .print-studio-controls, + body.print-studio-active .print-preview-toolbar, + body.print-studio-active .print-studio-footer { display: none !important; } + body.print-studio-active .print-studio-shell, + body.print-studio-active .print-studio-layout, + body.print-studio-active .print-preview-pane, + body.print-studio-active .print-preview-pages { + display: block !important; + width: auto !important; + height: auto !important; + overflow: visible !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + box-shadow: none !important; + background: #fff !important; + } + body.print-studio-active .print-page { + width: 210mm !important; + min-height: 297mm !important; + padding: 10mm !important; + margin: 0 !important; + transform: none !important; + box-shadow: none !important; + break-after: page; + page-break-after: always; + } + body.print-studio-active .print-page:last-child { break-after: auto; page-break-after: auto; } + body.print-studio-active .standard-labels .print-label-grid { + grid-template-columns: repeat(2, 90mm) !important; + grid-auto-rows: 50mm !important; + gap: 5mm 5mm !important; + } + body.print-studio-active .compact-labels .print-label-grid { + grid-template-columns: repeat(3, 62mm) !important; + grid-auto-rows: 29mm !important; + gap: 1.6mm 2mm !important; + } +} + +/* ========================================================================== + Listing Studio 2026 + ========================================================================== */ +.generator-studio-page { min-width: 0; } +.generator-heading { align-items: center; } +.generator-heading-actions { display: flex; align-items: center; gap: 12px; } +.generator-stepper { display: flex; align-items: center; gap: 6px; } +.generator-step { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 999px; + color: var(--vd-ink-muted); + background: var(--vd-surface-raised); + font-size: 10px; + font-weight: 680; + white-space: nowrap; +} +.generator-step b { + width: 17px; + height: 17px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; +} +.generator-step.is-active { border-color: var(--vd-brand); color: var(--vd-brand); } +.generator-step.is-active b { background: var(--vd-brand); color: white; } +.generator-step.is-complete { border-color: var(--vd-success); color: var(--vd-success); } +.generator-step.is-complete b { background: var(--vd-success); color: white; } + +.generator-studio-layout { + display: grid; + grid-template-columns: minmax(260px, .82fr) minmax(310px, 1fr) minmax(350px, 1.18fr); + gap: 12px; + align-items: start; +} +.generator-panel { + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + overflow: hidden; +} +.generator-panel-header { + min-height: 58px; + padding: 11px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--vd-line); +} +.generator-panel-header > div { display: flex; align-items: center; gap: 9px; min-width: 0; } +.generator-panel-header h3 { font-size: 12px; letter-spacing: -0.025em; } +.generator-panel-header p { margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.35; } +.generator-panel-index { + width: 27px; + height: 27px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-brand); + font: 720 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.generator-count, .generator-beta, .generator-preview-status { + min-height: 22px; + padding: 3px 8px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 720; + white-space: nowrap; +} +.generator-beta { background: var(--vd-brand-soft); color: var(--vd-brand); } +.generator-preview-status.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.generator-preview-status.is-working { background: var(--vd-warning-soft); color: var(--vd-warning); } + +.media-workbench { padding-bottom: 12px; } +.generator-photo-stage { + position: relative; + aspect-ratio: 1.2; + margin: 12px 12px 9px; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: + linear-gradient(45deg, var(--vd-surface-muted) 25%, transparent 25%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 9px -9px/18px 18px, + linear-gradient(45deg, var(--vd-surface-muted) 25%, var(--vd-surface) 25%) 9px -9px/18px 18px; +} +.generator-cover { width: 100%; height: 100%; display: block; object-fit: contain; object-position: center; padding: 8px; } +.generator-photo-empty { + width: 100%; + height: 100%; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 26px; + color: var(--vd-ink-muted); +} +.generator-photo-empty-mark { + width: 58px; + height: 58px; + display: grid; + place-items: center; + margin-bottom: 12px; + border-radius: 50%; + background: var(--vd-surface-raised); + border: 1px solid var(--vd-line); + color: var(--vd-brand); +} +.generator-photo-empty strong { color: var(--vd-ink); font-size: 12px; } +.generator-photo-empty span { max-width: 220px; margin-top: 6px; font-size: 10px; line-height: 1.5; } +.generator-cover-badge { + position: absolute; + left: 9px; + bottom: 9px; + min-height: 23px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--vd-ink) 82%, transparent); + color: var(--vd-surface); + font-size: 9px; + font-weight: 720; + backdrop-filter: blur(8px); +} +.generator-dropzone { + margin: 0 12px; + min-height: 92px; + padding: 13px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + text-align: left; + border-width: 1px; + border-radius: 8px; + background: var(--vd-surface); +} +.generator-dropzone-icon { + grid-row: 1 / 3; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + background: var(--vd-brand-soft); + color: var(--vd-brand); +} +.generator-dropzone p { font-size: 10px; line-height: 1.45; color: var(--vd-ink-secondary); } +.generator-dropzone p strong { color: var(--vd-ink); font-size: 11px; } +.generator-dropzone small { color: var(--vd-ink-muted); font-size: 8px; } +.generator-photo-strip { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin: 9px 12px 0; +} +.generator-photo-strip .thumb { + border: 1px solid var(--vd-line); + border-radius: 6px; + box-shadow: none; + background: var(--vd-surface-muted); +} +.generator-photo-strip .thumb:first-child { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.generator-photo-strip .thumb img { object-fit: contain; object-position: center; padding: 2px; } +.generator-photo-strip .thumb::after { + content: attr(data-index); + position: absolute; + left: 4px; + bottom: 4px; + min-width: 15px; + height: 15px; + display: grid; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--vd-ink) 76%, transparent); + color: var(--vd-surface); + font-size: 7px; + font-weight: 760; +} +.generator-image-tools { margin: 8px 12px 0; } +.generator-image-tools .small-btn { display: inline-flex; align-items: center; gap: 6px; } +.generator-photo-quality { + margin: 11px 12px 0; + padding: 11px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-photo-quality-head { display: flex; align-items: center; justify-content: space-between; } +.generator-photo-quality-head > div { display: flex; align-items: center; gap: 6px; } +.generator-photo-quality-head strong { font-size: 10px; } +.generator-photo-quality-head > span { color: var(--vd-ink-muted); font-size: 9px; font-weight: 700; } +.quality-spark { color: var(--vd-brand); } +.generator-photo-quality ul { list-style: none; margin-top: 8px; display: grid; gap: 6px; } +.generator-photo-quality li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.generator-photo-quality li > span { + width: 13px; + height: 13px; + border: 1px solid var(--vd-line-strong); + border-radius: 50%; + flex: 0 0 auto; +} +.generator-photo-quality li.is-complete { color: var(--vd-ink-secondary); } +.generator-photo-quality li.is-complete > span { border-color: var(--vd-success); background: var(--vd-success); position: relative; } +.generator-photo-quality li.is-complete > span::after { content: "✓"; position: absolute; inset: 0; display: grid; place-items: center; color: white; font-size: 8px; } + +.generator-config-panel { position: relative; } +.generator-config-stack { padding: 10px; display: grid; gap: 7px; } +.generator-config-card, .generator-notes-card { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-config-card { display: grid; gap: 8px; } +.generator-config-card.compact { align-content: start; } +.generator-config-copy label, .generator-notes-head label { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 720; text-transform: none; letter-spacing: 0; margin: 0; } +.generator-config-copy span, .generator-notes-head span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.4; } +.generator-choice-group { gap: 5px; } +.generator-choice-group .toggle-btn { + min-height: 28px; + padding: 0 9px; + border-width: 1px; + border-radius: 7px; + font-size: 9px; +} +.generator-choice-group .toggle-btn.active { background: var(--vd-brand); border-color: var(--vd-brand); } +.generator-config-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.generator-config-card select { min-height: 34px; padding: 6px 9px; font-size: 10px; } +.generator-template-tags { display: flex; gap: 4px; flex-wrap: wrap; } +.generator-template-tags span { + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 999px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 8px; + font-weight: 680; +} +.generator-notes-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.generator-notes-head > span { white-space: nowrap; } +.generator-notes-card textarea { + width: 100%; + margin-top: 8px; + min-height: 88px; + resize: vertical; + padding: 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + color: var(--vd-ink); + background: var(--vd-surface-raised); + font: 10px/1.55 inherit; +} +.generator-tone-row { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 7px; } +.generator-tone-row span { padding: 3px 6px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 7px; } +.generator-loading { margin: 0 10px 10px; padding: 18px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.generator-loading p { line-height: 1.5; } +.generator-loading p strong { color: var(--vd-ink); } +.generator-loading p span { font-size: 9px; } +.generator-action-zone { + position: sticky; + bottom: 0; + display: grid; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-raised) 94%, transparent); + backdrop-filter: blur(10px); +} +.generator-readiness { display: grid; grid-template-columns: 9px minmax(0, 1fr); gap: 8px; align-items: center; } +.generator-readiness > span { width: 7px; height: 7px; border-radius: 50%; background: var(--vd-warning); } +.generator-readiness.is-ready > span { background: var(--vd-success); } +.generator-readiness strong { display: block; font-size: 9px; } +.generator-readiness small { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; } +.generator-primary-action { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 11px; } + +.generator-preview-panel { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + scrollbar-width: thin; +} +.generator-preview-empty { min-height: 520px; display: grid; align-content: center; justify-items: center; text-align: center; padding: 34px; color: var(--vd-ink-muted); } +.generator-preview-empty-mark { + width: 62px; + height: 62px; + display: grid; + place-items: center; + border-radius: 18px 18px 18px 5px; + background: var(--vd-brand); + color: white; + font-size: 17px; + font-weight: 820; + letter-spacing: -.08em; + box-shadow: 0 12px 24px var(--vd-brand-soft); +} +.generator-preview-empty h3 { margin-top: 15px; color: var(--vd-ink); font-size: 14px; } +.generator-preview-empty p { max-width: 275px; margin-top: 7px; font-size: 10px; line-height: 1.55; } +.generator-preview-skeleton { width: min(280px, 100%); margin-top: 20px; display: grid; gap: 7px; } +.generator-preview-skeleton span { height: 9px; border-radius: 999px; background: var(--vd-surface-muted); } +.generator-preview-skeleton span:nth-child(2) { width: 82%; } +.generator-preview-skeleton span:nth-child(3) { width: 94%; } +.generator-preview-skeleton span:nth-child(4) { width: 58%; } +.generator-variant-wrap { padding: 10px 12px 0; } +.generator-section-label { color: var(--vd-ink-muted); font-size: 8px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.generator-result { margin: 0; padding: 12px; border: 0; border-radius: 0; background: transparent; } +.generator-result-score { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-result-score > div:first-child span { display: block; color: var(--vd-ink-muted); font-size: 8px; } +.generator-result-score > div:first-child strong { display: block; margin-top: 2px; color: var(--vd-success); font-size: 11px; } +.generator-quality-ring { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 50%; + background: conic-gradient(var(--vd-success) var(--quality), var(--vd-surface-muted) 0); + position: relative; +} +.generator-quality-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface); } +.generator-quality-ring span { position: relative; z-index: 1; font-size: 9px; font-weight: 780; } +.generator-result .result-field { margin-bottom: 11px; } +.generator-result .field-header > div { display: flex; align-items: center; gap: 6px; } +.generator-result .field-header label { color: var(--vd-ink-secondary); font-size: 9px; } +.field-count { color: var(--vd-ink-muted); font-size: 8px; } +.generator-result .result-input { padding: 8px 9px; font-size: 11px; } +.generator-result .ql-toolbar.ql-snow { border-color: var(--vd-line); border-radius: 7px 7px 0 0; background: var(--vd-surface); } +.generator-result .ql-container.ql-snow { min-height: 190px; border-color: var(--vd-line); border-radius: 0 0 7px 7px; font-size: 11px; } +.generator-meta-section { margin: 12px 0 0; padding: 10px; border-radius: 8px; background: var(--vd-surface); } +.generator-meta-section .meta-grid-edit { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 8px; } +.generator-meta-section .meta-category-item { grid-column: 1 / -1; } +.generator-meta-section .meta-label { font-size: 8px; } +.generator-meta-section .meta-input { padding: 7px 8px; font-size: 10px; } +.generator-meta-section .fee-info { padding-top: 8px; font-size: 9px; } +.generator-meta-section .fee-info .fee-net { font-size: 11px; } +.generator-html-details { margin-top: 11px; border: 1px solid var(--vd-line); border-radius: 8px; overflow: hidden; } +.generator-html-details summary { padding: 10px; cursor: pointer; color: var(--vd-ink-secondary); font-size: 9px; font-weight: 680; } +.generator-html-details summary span { color: var(--vd-ink-muted); font-weight: 500; } +.generator-html-actions { display: flex; justify-content: flex-end; gap: 5px; padding: 0 10px 8px; } +.generator-html-details .html-preview, .generator-html-details .html-source { margin: 0 10px 10px; width: calc(100% - 20px); } +.generator-result-actions { position: sticky; bottom: -12px; display: grid; grid-template-columns: auto 1fr; padding: 10px 0 0; background: var(--vd-surface-raised); } +.generator-result-actions button { min-height: 38px; } + +@media (max-width: 1280px) { + .generator-studio-layout { grid-template-columns: minmax(250px, .8fr) minmax(310px, 1fr); } + .generator-preview-panel { grid-column: 1 / -1; position: static; max-height: none; } + .generator-preview-empty { min-height: 260px; } +} + +@media (max-width: 900px) { + .generator-heading { align-items: flex-start; flex-direction: column; } + .generator-heading-actions { width: 100%; justify-content: space-between; } + .generator-studio-layout { grid-template-columns: 1fr; } + .generator-preview-panel { grid-column: auto; } +} + +@media (max-width: 560px) { + .generator-stepper { width: 100%; } + .generator-step { flex: 1; justify-content: center; padding: 0 5px; } + .generator-heading-actions { align-items: stretch; flex-direction: column; } + .generator-config-pair { grid-template-columns: 1fr; } + .generator-photo-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .generator-meta-section .meta-grid-edit { grid-template-columns: 1fr; } + .generator-meta-section .meta-category-item { grid-column: auto; } +} + +/* --- Slice 05: Publish Center 2026 --- */ +.publish-center-page { padding-bottom: 22px; } +.publish-center-heading { align-items: flex-end; } +.publish-heading-actions { display: flex; align-items: center; gap: 7px; } +.publish-view-tabs { + display: flex; + align-items: flex-end; + gap: 2px; + margin: 10px 0 0; + padding: 0 2px; + overflow-x: auto; + border-bottom: 1px solid var(--vd-line); + scrollbar-width: none; +} +.publish-view-tabs::-webkit-scrollbar { display: none; } +.publish-view-tab { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 680; + white-space: nowrap; + cursor: pointer; +} +.publish-view-tab:hover { color: var(--vd-ink); background: var(--vd-surface-muted); } +.publish-view-tab.active { color: var(--vd-ink); border-bottom-color: var(--vd-brand); background: var(--vd-surface-raised); } +.publish-tab-count, +.publish-tab-badge { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 5px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 8px; + font-weight: 760; +} +.publish-tab-badge { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-view-tab.active .publish-tab-count { background: var(--vd-brand-soft); color: var(--vd-brand); } + +.publish-center-grid { + min-height: 680px; + display: grid; + grid-template-columns: 264px minmax(0, 1fr) 282px; + border: 1px solid var(--vd-line); + border-top: 0; + border-radius: 0 0 var(--vd-radius-md) var(--vd-radius-md); + overflow: hidden; + background: var(--vd-surface-raised); +} +.publish-list-panel, +.publish-status-rail, +.publish-workspace-panel { min-width: 0; min-height: 0; } +.publish-list-panel { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-list-panel-head, +.publish-rail-heading, +.publish-operation-header, +.publish-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.publish-list-panel-head { padding: 14px 14px 11px; } +.publish-panel-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-ink-muted); + font-size: 7.5px; + font-weight: 780; + letter-spacing: .1em; + text-transform: uppercase; +} +.publish-list-panel-head h3, +.publish-rail-heading h3, +.publish-operation-header h3, +.publish-workspace-header h3 { margin: 0; color: var(--vd-ink); font-size: 13px; letter-spacing: -.015em; } +.publish-list-panel-head h3 span { color: var(--vd-ink-muted); font-weight: 560; } +.publish-list-panel-head select { + max-width: 112px; + min-height: 30px; + padding: 0 24px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-list-filters { padding: 0 12px 11px; display: grid; gap: 7px; border-bottom: 1px solid var(--vd-line); } +.publish-search-field { + min-height: 36px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); +} +.publish-search-field:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.publish-search-field input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--vd-ink); font: 9px/1 inherit; } +.publish-filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.publish-filter-row select { + min-width: 0; + min-height: 32px; + padding: 0 22px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-selection-bar { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 8px; +} +.publish-selection-bar label { display: inline-flex; align-items: center; gap: 6px; color: var(--vd-ink-secondary); cursor: pointer; } +.publish-selection-bar input, +.publish-card-check input { accent-color: var(--vd-brand); } +.publish-center-page .publish-list { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px; + overflow-y: auto; + scrollbar-width: thin; +} +.publish-list-card { + position: relative; + display: grid; + grid-template-columns: 16px 54px minmax(0, 1fr); + gap: 8px; + align-items: center; + min-height: 76px; + padding: 8px; + border: 1px solid transparent; + border-radius: 9px; + background: var(--vd-surface-raised); + cursor: pointer; + transition: border-color .16s ease, background .16s ease, transform .16s ease; +} +.publish-list-card:hover { border-color: var(--vd-line-strong); transform: translateY(-1px); } +.publish-list-card.is-active { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.publish-card-check { align-self: start; padding-top: 4px; cursor: pointer; } +.publish-card-media { + width: 54px; + height: 58px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-card-media img { width: 100%; height: 100%; object-fit: contain; object-position: center; padding: 3px; } +.publish-card-copy { min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-right: 4px; } +.publish-card-copy > strong { color: var(--vd-ink); font-size: 9px; line-height: 1.25; font-weight: 720; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.publish-card-sku { margin-top: 3px; color: var(--vd-ink-muted); font: 7.5px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-card-meta { margin-top: 7px; display: flex; align-items: center; justify-content: space-between; gap: 7px; color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-card-meta b { color: var(--vd-ink); font-size: 8.5px; font-weight: 720; } +.publish-card-status { + position: absolute; + right: 7px; + bottom: 7px; + min-height: 17px; + display: inline-flex; + align-items: center; + padding: 0 5px; + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 720; +} +.publish-card-status.status-published { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-card-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-card-status.status-pending, +.publish-card-status.status-uploading { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-card-status.status-copied { background: var(--vd-info-soft); color: var(--vd-info); } +.publish-card-status.status-prepared { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-batch-bar { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 9px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-batch-bar .vd-button { flex: 1 1 auto; min-width: 68px; } + +.publish-workspace-panel { background: var(--vd-surface-raised); } +.publish-workspace { min-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; } +.publish-workspace-header { + min-height: 68px; + padding: 11px 14px; + border-bottom: 1px solid var(--vd-line); +} +.publish-workspace-product { min-width: 0; display: flex; align-items: center; gap: 10px; } +.publish-workspace-thumb { + width: 42px; + height: 42px; + flex: 0 0 auto; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-workspace-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 2px; } +.publish-workspace-product > div:last-child { min-width: 0; } +.publish-workspace-product h3 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.publish-workspace-product p { margin-top: 3px; color: var(--vd-ink-muted); font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-workspace-header-actions { display: flex; align-items: center; gap: 5px; } +.publish-workspace-state { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 999px; background: var(--vd-success-soft); color: var(--vd-success); font-size: 8px; font-weight: 720; } +.icon-button { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + cursor: pointer; +} +.icon-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.icon-button.danger { color: var(--vd-danger); } +.icon-button.is-loading, +.vd-button.is-loading { animation: vd-publish-spin .8s linear infinite; } +@keyframes vd-publish-spin { to { transform: rotate(360deg); } } +.publish-workspace-loading, +.publish-workspace-empty, +.publish-workspace-error { + min-height: 440px; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 32px; + text-align: center; + color: var(--vd-ink-muted); +} +.publish-workspace-loading span { width: 22px; height: 22px; border: 2px solid var(--vd-line); border-top-color: var(--vd-brand); border-radius: 50%; animation: vd-publish-spin .8s linear infinite; } +.publish-workspace-empty > span, +.publish-workspace-error > span { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 14px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; } +.publish-workspace-empty h3, +.publish-workspace-error strong { color: var(--vd-ink); font-size: 13px; } +.publish-workspace-empty p, +.publish-workspace-error p { max-width: 340px; font-size: 9px; line-height: 1.55; } +.publish-workspace-empty.compact { min-height: 320px; } +.publish-smart-grid, +.publish-direct-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 226px; + overflow-y: auto; +} +.publish-editor-column { min-width: 0; padding: 13px; display: grid; align-content: start; gap: 10px; } +.publish-editor-section { padding-bottom: 10px; border-bottom: 1px solid var(--vd-line); } +.publish-editor-section:last-child { border-bottom: 0; } +.publish-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } +.publish-field-head label { color: var(--vd-ink-secondary); font-size: 8.5px; font-weight: 720; } +.publish-field-head span { color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-field-head > div { display: flex; gap: 4px; } +.publish-field-head button, +.publish-copy-field button { + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 7.5px; + font-weight: 680; + cursor: pointer; +} +.publish-copy-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; } +.publish-copy-field input, +.publish-editor-section > input, +.publish-editor-section textarea, +.publish-meta-form input { + width: 100%; + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: 9px/1.45 inherit; +} +.publish-copy-field input, +.publish-editor-section > input, +.publish-meta-form input { min-height: 34px; padding: 0 9px; } +.publish-editor-section textarea { min-height: 190px; padding: 9px; resize: vertical; } +.publish-copy-field input[readonly], +.publish-editor-section textarea[readonly] { color: var(--vd-ink-secondary); background: var(--vd-surface-muted); } +.publish-meta-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; } +.publish-meta-form.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.publish-meta-form label { min-width: 0; display: grid; gap: 4px; } +.publish-meta-form label > span { color: var(--vd-ink-muted); font-size: 7.5px; font-weight: 680; } +.publish-meta-form.compact .publish-copy-field { grid-template-columns: minmax(0, 1fr); } +.publish-meta-form.compact .publish-copy-field button { display: none; } +.publish-readiness-column { + min-width: 0; + padding: 13px; + display: grid; + align-content: start; + gap: 10px; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-readiness-card, +.publish-photo-section { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-raised); +} +.publish-readiness-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.publish-readiness-head h4 { margin: 0; font-size: 12px; } +.publish-platform-wordmark { color: var(--vd-brand); font-size: 9px; font-weight: 800; } +.integration-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--vd-danger); box-shadow: 0 0 0 4px var(--vd-danger-soft); } +.integration-dot.is-connected { background: var(--vd-success); box-shadow: 0 0 0 4px var(--vd-success-soft); } +.publish-integration-message { margin: 8px 0 4px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.publish-readiness-card ul { list-style: none; margin: 10px 0; display: grid; gap: 6px; } +.publish-readiness-card li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-secondary); font-size: 8.5px; } +.publish-readiness-card li > span { width: 15px; height: 15px; display: grid; place-items: center; border-radius: 50%; font-size: 8px; font-weight: 800; } +.publish-readiness-card li.is-ready > span { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-readiness-card li.is-missing > span { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message { padding: 9px; border-radius: 7px; background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-ready-message strong, +.publish-ready-message small { display: block; } +.publish-ready-message strong { font-size: 9px; } +.publish-ready-message small { margin-top: 2px; color: inherit; opacity: .8; font-size: 7.5px; line-height: 1.4; } +.publish-photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } +.publish-photo-grid img, +.publish-photo-grid > span { + width: 100%; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; + object-fit: contain; + padding: 2px; +} +.publish-no-photos { min-height: 70px; display: grid; place-items: center; border: 1px dashed var(--vd-line-strong); border-radius: 7px; color: var(--vd-ink-muted); font-size: 8px; } +.vd-button.full { width: 100%; margin-top: 7px; justify-content: center; } +.publish-workspace-footer { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 13px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-workspace-footer > div { display: flex; gap: 6px; } + +.publish-status-rail { + overflow-y: auto; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); + scrollbar-width: thin; +} +.publish-rail-section { padding: 14px; border-bottom: 1px solid var(--vd-line); } +.publish-rail-heading { margin-bottom: 10px; } +.publish-status-list { display: grid; } +.publish-status-row { + width: 100%; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--vd-line); + background: transparent; + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-status-row:last-child { border-bottom: 0; } +.publish-status-row:hover strong { color: var(--vd-brand); } +.publish-status-icon { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--vd-line); border-radius: 50%; font-size: 9px; font-weight: 800; } +.publish-status-icon.pending { color: var(--vd-ink-muted); } +.publish-status-icon.processing { color: var(--vd-info); border-color: var(--vd-info); } +.publish-status-icon.success { color: var(--vd-success); border-color: var(--vd-success); } +.publish-status-icon.danger { color: var(--vd-danger); border-color: var(--vd-danger); } +.publish-status-icon.scheduled { color: var(--vd-warning); border-color: var(--vd-warning); } +.publish-status-row strong, +.publish-status-row small { display: block; } +.publish-status-row strong { font-size: 8.5px; } +.publish-status-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-status-row b { color: var(--vd-ink); font-size: 10px; } +.publish-quick-actions { display: grid; gap: 7px; } +.publish-quick-action { + width: 100%; + min-height: 45px; + display: grid; + align-content: center; + padding: 8px 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-quick-action:hover { border-color: var(--vd-line-strong); } +.publish-quick-action.primary { border-color: var(--vd-brand); background: var(--vd-brand); color: white; } +.publish-quick-action strong, +.publish-quick-action small { display: block; } +.publish-quick-action strong { font-size: 8.5px; } +.publish-quick-action small { margin-top: 2px; color: inherit; opacity: .72; font-size: 7px; } +.publish-activity-list { display: grid; gap: 8px; } +.publish-activity-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; } +.publish-activity-icon { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; } +.publish-activity-icon.type-published, +.publish-activity-icon.type-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-activity-icon.type-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-activity-icon.type-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-activity-row strong, +.publish-activity-row small { display: block; } +.publish-activity-row strong { color: var(--vd-ink-secondary); font-size: 7.8px; line-height: 1.35; font-weight: 620; } +.publish-activity-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-rail-empty { color: var(--vd-ink-muted); font-size: 8px; } +.publish-empty-state { min-height: 180px; display: grid; place-content: center; justify-items: center; gap: 4px; padding: 20px; text-align: center; } +.publish-empty-state strong { color: var(--vd-ink); font-size: 10px; } +.publish-empty-state span { color: var(--vd-ink-muted); font-size: 8px; } + +.publish-operation-header { + min-height: 74px; + padding: 13px 15px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-header p { margin-top: 3px; color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-header > div:last-child { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; } +.publish-operation-list, +.publish-schedule-list { min-height: 0; padding: 10px 13px; overflow-y: auto; } +.publish-operation-row { + min-height: 64px; + display: grid; + grid-template-columns: minmax(180px, 1.5fr) 100px 64px minmax(100px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-row:hover { background: var(--vd-surface); } +.publish-operation-product { min-width: 0; display: flex; align-items: center; gap: 8px; } +.publish-operation-mark { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 8px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-product strong, +.publish-operation-product small { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-product strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-operation-product small { margin-top: 3px; color: var(--vd-ink-muted); font: 7px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-meta span, +.publish-operation-meta b { display: block; } +.publish-operation-meta span { color: var(--vd-ink-muted); font-size: 7px; } +.publish-operation-meta b { margin-top: 3px; color: var(--vd-ink); font-size: 8px; } +.operation-status { display: inline-flex !important; width: fit-content; min-height: 19px; align-items: center; padding: 0 6px; border-radius: 5px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary) !important; } +.operation-status.status-published { background: var(--vd-success-soft); color: var(--vd-success) !important; } +.operation-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger) !important; } +.operation-status.status-uploading, +.operation-status.status-pending, +.operation-status.status-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning) !important; } +.publish-operation-error { min-width: 0; color: var(--vd-ink-muted); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } +.publish-operation-footer { min-height: 55px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 13px; border-top: 1px solid var(--vd-line); color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-footer strong { color: var(--vd-ink); } +.publish-schedule-row { + min-height: 62px; + display: grid; + grid-template-columns: 135px minmax(190px, 1fr) 90px minmax(80px, .7fr) 30px; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-schedule-date strong, +.publish-schedule-date span { display: block; } +.publish-schedule-date strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-schedule-date span { margin-top: 3px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-schedule-error { min-width: 0; color: var(--vd-danger); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.publish-schedule-dialog { width: min(620px, 94vw); max-width: 620px; } +.publish-schedule-summary { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 13px; } +.publish-schedule-summary span, +.publish-schedule-summary b { padding: 4px 7px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 7.5px; font-weight: 620; } +.publish-schedule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.publish-schedule-grid label { display: grid; gap: 5px; color: var(--vd-ink-secondary); font-size: 8px; font-weight: 680; } +.publish-schedule-grid select, +.publish-schedule-grid input { min-height: 40px; padding: 0 10px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface-raised); color: var(--vd-ink); font-size: 9px; } +.publish-schedule-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 16px; } + +@media (max-width: 1320px) { + .publish-center-grid { grid-template-columns: 245px minmax(0, 1fr); } + .publish-status-rail { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr 1fr; border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-rail-section { border-bottom: 0; border-right: 1px solid var(--vd-line); } + .publish-rail-section:last-child { border-right: 0; } +} +@media (max-width: 960px) { + .publish-center-heading { align-items: flex-start; flex-direction: column; } + .publish-center-grid { grid-template-columns: 1fr; } + .publish-list-panel { max-height: 470px; border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-smart-grid, + .publish-direct-grid { grid-template-columns: 1fr; } + .publish-readiness-column { border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-status-rail { grid-template-columns: 1fr; } + .publish-rail-section { border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-operation-row { grid-template-columns: minmax(170px, 1fr) 90px 60px auto; } + .publish-operation-error { display: none; } +} +@media (max-width: 620px) { + .publish-heading-actions { width: 100%; } + .publish-heading-actions .vd-button { flex: 1; } + .publish-view-tab { padding: 0 10px; } + .publish-list-panel-head { align-items: flex-start; } + .publish-filter-row, + .publish-schedule-grid, + .publish-meta-form, + .publish-meta-form.compact { grid-template-columns: 1fr; } + .publish-workspace-header { align-items: flex-start; } + .publish-workspace-header-actions { flex-wrap: wrap; justify-content: flex-end; } + .publish-workspace-footer, + .publish-operation-footer { align-items: stretch; flex-direction: column; } + .publish-workspace-footer > div { display: grid; grid-template-columns: 1fr 1fr; } + .publish-workspace-footer .vd-button { justify-content: center; } + .publish-operation-header { align-items: flex-start; flex-direction: column; } + .publish-operation-header > div:last-child { width: 100%; justify-content: stretch; } + .publish-operation-header .vd-button { flex: 1; } + .publish-operation-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .publish-operation-meta, + .publish-operation-error { display: none; } + .publish-schedule-row { grid-template-columns: 1fr auto; } + .publish-schedule-row > :nth-child(3), + .publish-schedule-row > :nth-child(4) { display: none; } +} + +/* ========================================================================== + Slice 06 — Full Listing Editor + Live WYSIWYG/HTML synchronization + ========================================================================== */ +.generator-html-workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + padding: 0 10px 10px; +} +.generator-html-pane { + min-width: 0; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); +} +.generator-html-pane-head, +.detail-pane-head, +.detail-html-preview-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.generator-html-pane-head strong, +.detail-pane-head strong, +.detail-html-preview-head strong { color: var(--vd-ink); font-size: 8px; } +.generator-html-pane-head span, +.detail-pane-head span, +.detail-html-preview-head span { color: var(--vd-ink-muted); font-size: 7px; } +.generator-html-pane-head span[data-tone="working"], +.detail-html-preview-head span[data-tone="working"] { color: var(--vd-warning); } +.generator-html-pane-head span[data-tone="ok"], +.detail-html-preview-head span[data-tone="ok"] { color: var(--vd-success); } +.generator-html-pane-head span[data-tone="warning"], +.detail-html-preview-head span[data-tone="warning"] { color: var(--vd-warning); } +.generator-html-pane .html-source { + min-height: 240px; + margin: 0; + width: 100%; + border: 0; + border-radius: 0; + padding: 11px; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.html-preview-frame { + display: block; + width: 100%; + min-height: 240px; + margin: 0 !important; + border: 0; + background: #fff; +} + +.listings-page .history-detail { + padding: 0; + overflow: hidden; + border-color: var(--vd-line); + border-radius: 12px; + background: var(--vd-surface-raised); +} +.listings-page #back-to-list { margin: 12px 14px 0; } +.listing-editor-shell { padding: 0 14px 16px; } +.listing-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 17px 0 14px; + border-bottom: 1px solid var(--vd-line); +} +.listing-editor-kicker { + display: block; + margin-bottom: 4px; + color: var(--vd-brand); + font-size: 7px; + font-weight: 720; + letter-spacing: .08em; + text-transform: uppercase; +} +.listing-editor-header h2 { margin: 0; color: var(--vd-ink); font-size: 18px; line-height: 1.2; text-transform: none; letter-spacing: -.02em; } +.listing-editor-header p { margin: 5px 0 0; color: var(--vd-ink-muted); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: normal; } +.listing-editor-header-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } +.listing-editor-grid { + display: grid; + grid-template-columns: minmax(220px, .78fr) minmax(420px, 1.65fr) minmax(190px, .62fr); + gap: 12px; + align-items: start; + padding-top: 13px; +} +.listing-media-editor, +.listing-content-editor, +.listing-context-panel { min-width: 0; } +.listing-media-editor, +.listing-editor-section, +.listing-context-card { + border: 1px solid var(--vd-line); + border-radius: 10px; + background: var(--vd-surface-raised); +} +.listing-media-editor { padding: 10px; position: sticky; top: 74px; } +.listing-content-editor { display: grid; gap: 10px; } +.listing-editor-section { padding: 11px; } +.listing-context-panel { display: grid; gap: 9px; position: sticky; top: 74px; } +.listing-context-card { padding: 11px; } +.listing-context-card h3, +.listing-section-heading h3 { margin: 0; color: var(--vd-ink); font-size: 9px; text-transform: none; letter-spacing: 0; } +.listing-section-heading { + min-height: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.listing-section-heading > div { display: flex; align-items: center; gap: 7px; } +.listing-section-heading > div > span { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 7px; + font-weight: 760; +} +.listing-section-heading > b { color: var(--vd-ink-muted); font-size: 7px; font-weight: 600; } +.detail-photo-stage { + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-muted); +} +.detail-photo-stage img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-empty { color: var(--vd-ink-muted); font-size: 8px; } +.detail-photo-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + margin-top: 7px; +} +.detail-photo-thumb { + position: relative; + aspect-ratio: 1; + overflow: hidden; + padding: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + cursor: pointer; +} +.detail-photo-thumb:hover, +.detail-photo-thumb.is-active { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.detail-photo-thumb.is-dragging { opacity: .45; } +.detail-photo-thumb img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-thumb span { + position: absolute; + left: 3px; + bottom: 3px; + padding: 2px 4px; + border-radius: 4px; + background: rgba(21, 20, 17, .82); + color: #fff; + font-size: 5.5px; + font-weight: 720; +} +.detail-photo-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 8px; } +.detail-photo-actions .vd-button { justify-content: center; min-width: 0; padding-inline: 6px; } +.detail-photo-help { margin: 8px 0 0 !important; color: var(--vd-ink-muted); font-size: 6.5px !important; line-height: 1.5 !important; white-space: normal !important; } +.listing-form-grid { display: grid; gap: 8px; } +.listing-form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.listing-form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.listing-form-grid label, +.listing-full-field { + min-width: 0; + display: grid; + gap: 5px; + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 680; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field { position: relative; margin-top: 9px; } +.listing-full-field > span { position: absolute; right: 1px; top: 0; color: var(--vd-ink-muted); font-size: 6.5px; font-weight: 500; } +.listing-form-grid input, +.listing-form-grid select, +.listing-full-field input, +.listing-full-field textarea { + width: 100%; + min-height: 38px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: inherit; + font-size: 8px; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field textarea { min-height: 76px; padding-block: 9px; resize: vertical; } +.listing-form-grid input:focus, +.listing-form-grid select:focus, +.listing-full-field input:focus, +.listing-full-field textarea:focus, +.detail-description-pane textarea:focus { outline: none; border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-brand-soft); } +.detail-description-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } +.detail-description-pane { min-width: 0; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.detail-description-pane #detail-description-editor { min-height: 245px; } +.detail-description-pane .ql-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-description-pane .ql-container { border: 0; min-height: 201px; font-size: 9px; } +.detail-description-pane textarea { + display: block; + width: 100%; + min-height: 245px; + padding: 10px; + border: 0; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.detail-html-preview-head { margin-top: 8px; border: 1px solid var(--vd-line); border-bottom: 0; border-radius: 8px 8px 0 0; } +.detail-html-preview { display: block; width: 100%; min-height: 280px; border: 1px solid var(--vd-line); border-radius: 0 0 8px 8px; background: #fff; } +.listing-context-card canvas { display: block; width: 118px; height: 118px; margin: 2px auto 8px; padding: 7px; border: 1px solid var(--vd-line); border-radius: 8px; background: #fff; } +.listing-context-card > strong { display: block; overflow-wrap: anywhere; color: var(--vd-ink); text-align: center; font: 8px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.listing-context-card .vd-button.full { width: 100%; justify-content: center; margin-top: 6px; } +.listing-context-card.danger-zone { border-color: color-mix(in srgb, var(--vd-danger) 35%, var(--vd-line)); } + +@media (max-width: 1280px) { + .listing-editor-grid { grid-template-columns: 230px minmax(0, 1fr); } + .listing-context-panel { grid-column: 1 / -1; position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .listing-context-card { min-height: 120px; } +} +@media (max-width: 920px) { + .generator-html-workbench, + .detail-description-split { grid-template-columns: 1fr; } + .listing-editor-grid { grid-template-columns: 1fr; } + .listing-media-editor, + .listing-context-panel { position: static; } + .listing-context-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-photo-stage { max-height: 470px; aspect-ratio: 4 / 3; } +} +@media (max-width: 620px) { + .listing-editor-header { flex-direction: column; } + .listing-editor-header-actions { width: 100%; } + .listing-editor-header-actions .vd-button { flex: 1; justify-content: center; } + .listing-form-grid.three, + .listing-form-grid.two, + .listing-context-panel { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .generator-html-workbench { padding-inline: 7px; } +} + +.history-detail .detail-photo-stage img, .history-detail .detail-photo-thumb img { object-fit: contain; object-position: center; } + +/* ========================================================================== + Slice 07: Cross-browser extensions, responsive shell & mobile photo upload + ========================================================================== */ +html, body, .main-wrapper, main, .tab-content { min-width: 0; } +img, video, canvas, iframe { max-width: 100%; } + +.mobile-upload-trigger { + width: calc(100% - 24px); + min-height: 52px; + margin: 8px 12px 0; + padding: 8px 10px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + text-align: left; + cursor: pointer; +} +.mobile-upload-trigger:hover { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.mobile-upload-trigger-icon { + width: 32px; height: 32px; display: grid; place-items: center; + border-radius: 8px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; font-weight: 800; +} +.mobile-upload-trigger span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.mobile-upload-trigger strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-trigger small { color: var(--vd-ink-muted); font-size: 7px; line-height: 1.35; } +.mobile-upload-trigger b { padding: 4px 6px; border-radius: 5px; background: var(--vd-ink); color: var(--vd-surface); font-size: 7px; } + +.html-view-toolbar { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 10px; + border-top: 1px solid var(--vd-line); + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.html-view-tabs { display: inline-flex; gap: 3px; padding: 3px; border-radius: 7px; background: var(--vd-surface-muted); } +.html-view-tab { + min-height: 28px; padding: 0 9px; border: 0; border-radius: 5px; + background: transparent; color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; cursor: pointer; +} +.html-view-tab.is-active { background: var(--vd-surface-raised); color: var(--vd-ink); box-shadow: 0 1px 5px rgba(20,18,15,.08); } +.html-switch-stage { min-width: 0; } +.html-switch-view { display: none; min-width: 0; } +.html-switch-view.is-active { display: block; } +.html-switch-view .html-source, +.detail-html-switch-stage textarea { + display: block; width: 100%; min-height: 290px; margin: 0; padding: 12px; + border: 0; border-radius: 0; resize: vertical; background: var(--vd-code-bg, #171714); color: #f3f0e7; + font: 8px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; +} +.html-switch-view .html-preview-frame { min-height: 320px; } +.detail-platform-workbench { margin-top: 9px; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.detail-html-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-html-toolbar > span { color: var(--vd-ink-muted); font-size: 7px; } +.detail-html-switch-stage .detail-html-preview { min-height: 340px; border: 0; border-radius: 0; } +.detail-wysiwyg-pane { width: 100%; } +.detail-wysiwyg-pane #detail-description-editor { min-height: 270px; } +.detail-photo-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.mobile-upload-overlay { z-index: 2500; padding: 18px; } +.mobile-upload-dialog { + width: min(760px, 100%); max-height: min(760px, calc(100dvh - 36px)); overflow: auto; + border: 1px solid var(--vd-line); border-radius: 16px; background: var(--vd-surface-raised); box-shadow: var(--vd-shadow-float); +} +.mobile-upload-dialog > header, +.mobile-upload-dialog > footer { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; } +.mobile-upload-dialog > header { border-bottom: 1px solid var(--vd-line); } +.mobile-upload-dialog > footer { justify-content: flex-end; border-top: 1px solid var(--vd-line); } +.mobile-upload-dialog-brand { display: flex; align-items: center; gap: 10px; } +.mobile-upload-dialog-brand > div { display: grid; gap: 2px; } +.mobile-upload-dialog-brand span { color: var(--vd-brand); font-size: 7px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.mobile-upload-dialog-brand h3 { margin: 0; color: var(--vd-ink); font-size: 15px; } +.mobile-upload-dialog-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: var(--vd-brand); color: #fff !important; font-size: 9px !important; } +.mobile-upload-dialog-grid { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 22px; padding: 22px; } +.mobile-upload-qr-card { display: grid; justify-items: center; align-content: center; padding: 18px; border: 1px solid var(--vd-line); border-radius: 13px; background: #fff; } +.mobile-upload-qr-card canvas { width: 240px; height: 240px; padding: 8px; background: #fff; } +.mobile-upload-session-state { min-height: 28px; display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 0 9px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; } +.mobile-upload-session-state i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.mobile-upload-session-state[data-tone="ready"] { background: var(--vd-success-soft); color: var(--vd-success); } +.mobile-upload-session-state[data-tone="warning"] { background: var(--vd-warning-soft); color: var(--vd-warning); } +.mobile-upload-session-state[data-tone="error"] { background: var(--vd-danger-soft); color: var(--vd-danger); } +.mobile-upload-dialog-copy { min-width: 0; align-self: center; } +.mobile-upload-dialog-copy h4 { margin: 4px 0 12px; color: var(--vd-ink); font-size: 21px; line-height: 1.15; letter-spacing: -.04em; } +.mobile-upload-dialog-copy ol { display: grid; gap: 9px; margin: 0 0 16px; padding-left: 22px; color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.45; } +.mobile-upload-link-wrap { display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.mobile-upload-link-wrap input { min-width: 0; height: 38px; border: 0; border-radius: 0; padding: 0 9px; background: var(--vd-surface); color: var(--vd-ink-secondary); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.mobile-upload-link-wrap button { border: 0; border-left: 1px solid var(--vd-line); padding: 0 10px; background: var(--vd-surface-muted); color: var(--vd-brand); font-size: 8px; font-weight: 720; cursor: pointer; } +.mobile-upload-dialog-copy > p { margin: 9px 0 0; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.mobile-upload-live { display: flex; justify-content: space-between; gap: 10px; margin-top: 15px; padding: 10px; border-radius: 8px; background: var(--vd-surface-muted); } +.mobile-upload-live strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-live span { color: var(--vd-ink-muted); font-size: 8px; } + +/* Fluid page widths and device adaptations */ +.main-wrapper { width: calc(100% - var(--vd-sidebar-width)); max-width: 100%; } +main { width: 100%; overflow-x: clip; } +.history-table-wrap, .publish-workspace, .storage-main, .admin-grid, .settings-form { min-width: 0; } +.history-table-wrap { overflow-x: auto; } +.history-table { min-width: 980px; } +.generator-panel, .work-panel, .listing-editor-section, .listing-media-editor, .listing-context-card { max-width: 100%; } + +@media (max-width: 1440px) { + .generator-studio-layout { grid-template-columns: minmax(240px,.72fr) minmax(300px,.95fr) minmax(330px,1.1fr); } + main { padding-inline: 18px; } +} +@media (max-width: 1180px) { + .topbar-context { min-width: 150px; } + .command-search { width: min(390px, 34vw); } + .listing-editor-grid { grid-template-columns: 230px minmax(0,1fr); } + .listing-context-panel { grid-template-columns: repeat(2,minmax(0,1fr)); } +} +@media (max-width: 1024px) { + :root { --vd-sidebar-width: 74px; } + .sidebar-header { justify-content: center; padding-inline: 10px; } + .logo-text, .nav-item span, .nav-group-label, .sidebar-footer .theme-toggle span { display: none; } + .nav-item { justify-content: center; padding: 0; } + .nav-item::before { left: -10px; } + .sidebar-footer .theme-toggle { justify-content: center; padding: 0; } + .main-wrapper { width: calc(100% - var(--vd-sidebar-width)); } + .topbar { padding-inline: 14px; } + .generator-studio-layout { grid-template-columns: 1fr 1fr; } + .generator-preview-panel { grid-column: 1 / -1; } + .studio-layout { grid-template-columns: 1fr; } + .studio-inspector { position: static; } + .publish-center-layout { grid-template-columns: minmax(0,1fr); } + .mobile-upload-dialog-grid { grid-template-columns: 250px minmax(0,1fr); gap: 16px; } +} +@media (max-width: 768px) { + :root { --vd-sidebar-width: 0px; } + body { display: block; } + .main-wrapper { width: 100%; margin-left: 0; } + .topbar { position: sticky; top: 0; z-index: 100; } + .topbar-context { flex: 0 1 auto; } + .topbar-actions { gap: 3px; } + main { padding: 13px 10px 22px; } + .page-heading { align-items: flex-start; flex-direction: column; gap: 10px; } + .generator-studio-layout, .listing-editor-grid { grid-template-columns: 1fr; } + .generator-preview-panel, .listing-media-editor, .listing-context-panel { position: static; max-height: none; } + .listing-context-panel { grid-template-columns: 1fr 1fr; } + .studio-bottom-grid { grid-template-columns: 1fr; } + .flow-board { min-width: 860px; } + .mobile-upload-dialog-grid { grid-template-columns: 1fr; padding: 15px; } + .mobile-upload-qr-card canvas { width: 210px; height: 210px; } + .mobile-upload-dialog-copy { text-align: left; } + .html-view-toolbar { align-items: flex-start; flex-direction: column; } + .generator-html-actions { width: 100%; justify-content: flex-start; overflow-x: auto; padding: 0; } +} +@media (max-width: 560px) { + .topbar { min-height: 54px; height: 54px; } + .topbar-title { max-width: 105px; } + .command-search { max-width: 42px; padding: 0; justify-content: center; } + .command-search svg { margin: 0; } + .generator-stepper { overflow-x: auto; justify-content: flex-start; } + .generator-step { min-width: max-content; flex: 0 0 auto; } + .listing-context-panel, .listing-form-grid.two, .listing-form-grid.three { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .mobile-upload-overlay { padding: 7px; align-items: flex-end; } + .mobile-upload-dialog { max-height: calc(100dvh - 14px); border-radius: 16px 16px 8px 8px; } + .mobile-upload-dialog > header, .mobile-upload-dialog > footer { padding-inline: 12px; } + .mobile-upload-dialog > footer { display: grid; grid-template-columns: 1fr 1fr; } + .mobile-upload-dialog > footer button { min-width: 0; } + .mobile-upload-live { align-items: flex-start; flex-direction: column; } + .html-view-tabs { width: 100%; } + .html-view-tab { flex: 1; } + .history-table { min-width: 850px; } +} + +/* ========================================================================== + Slice 10 — Dashboard, Studio Flow V2 & responsive hardening + ========================================================================== */ +.dashboard-page,.studio-flow-v2{min-width:0} +.dashboard-heading{align-items:center}.dashboard-controls{display:flex;align-items:center;gap:8px}.dashboard-controls select{height:34px;padding:0 34px 0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:11px} +.dashboard-layout{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:14px;align-items:start}.dashboard-main{min-width:0}.dashboard-rail{display:grid;gap:12px;min-width:0}.dashboard-card{min-width:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);box-shadow:none;overflow:hidden}.dashboard-card>header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 13px 9px}.dashboard-card>header>div{display:grid;gap:3px}.dashboard-card>header span{font-size:12px;font-weight:750;color:var(--vd-ink)}.dashboard-card>header strong{font-size:10px;font-weight:500;color:var(--vd-ink-muted)} +.dashboard-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:12px}.dashboard-kpi{position:relative;min-width:0;min-height:116px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);overflow:hidden}.dashboard-kpi-icon{width:24px;height:24px;display:grid;place-items:center;border-radius:var(--vd-radius-xs);background:var(--vd-success-soft);color:var(--vd-success);font-size:11px;font-weight:800}.dashboard-kpi>div{display:grid;gap:3px;margin-top:7px}.dashboard-kpi small{font-size:9px;color:var(--vd-ink-secondary)}.dashboard-kpi strong{font-size:17px;letter-spacing:-.035em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-kpi em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.dashboard-kpi svg{position:absolute;left:12px;right:12px;bottom:8px;width:calc(100% - 24px);height:25px;color:var(--vd-success)}.dashboard-kpi svg[data-tone="blue"]{color:var(--vd-info)}.dashboard-kpi svg[data-tone="purple"]{color:#8054b3}.dashboard-kpi svg[data-tone="orange"]{color:var(--vd-brand)} +.dashboard-chart-grid{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:12px;margin-bottom:12px}.dashboard-chart{height:250px;padding:0 8px 8px}.dashboard-chart svg{width:100%;height:100%;overflow:visible}.chart-grid line{stroke:var(--vd-line);stroke-width:1}.chart-grid text{fill:var(--vd-ink-muted);font-size:10px}.chart-area{fill:var(--vd-brand-soft)}.chart-line{fill:none;stroke:var(--vd-brand);stroke-width:2.5;vector-effect:non-scaling-stroke}.chart-point{fill:var(--vd-surface-raised);stroke:var(--vd-brand);stroke-width:2;cursor:pointer}.dashboard-bar-chart{height:250px;display:flex;align-items:end;justify-content:space-around;gap:12px;padding:16px 14px 12px}.dashboard-bar-item{height:100%;min-width:0;flex:1;display:grid;grid-template-rows:18px 1fr 18px;gap:5px;text-align:center}.dashboard-bar-item>strong{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap}.dashboard-bar-item>div{display:flex;align-items:end;justify-content:center;border-bottom:1px solid var(--vd-line)}.dashboard-bar-item i{display:block;width:min(44px,72%);min-height:5px;background:var(--vd-info);border-radius:var(--vd-radius-xs) var(--vd-radius-xs) 0 0}.dashboard-bar-item:nth-child(2) i{background:var(--vd-success)}.dashboard-bar-item:nth-child(3) i{background:var(--vd-ink)}.dashboard-bar-item:nth-child(4) i{background:var(--vd-brand)}.dashboard-bar-item span{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-category-chart{min-height:250px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:14px;align-items:center;padding:12px 14px}.dashboard-donut{width:138px;aspect-ratio:1;border-radius:50%;display:grid;place-items:center}.dashboard-donut>div{width:72%;aspect-ratio:1;border-radius:50%;display:grid;place-items:center;align-content:center;background:var(--vd-surface-raised);text-align:center}.dashboard-donut strong{font-size:13px}.dashboard-donut span{font-size:9px;color:var(--vd-ink-muted)}.dashboard-category-legend{display:grid;gap:8px}.dashboard-category-legend>div{display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:7px;align-items:center;font-size:9px}.dashboard-category-legend i{width:7px;height:7px;border-radius:50%}.dashboard-category-legend span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-category-legend strong{font-size:9px} +.dashboard-focus-card{margin-bottom:12px}.dashboard-focus-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;padding:0 12px 12px}.dashboard-focus-item{min-height:108px;padding:11px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface);color:var(--vd-ink);display:grid;grid-template-rows:auto 1fr auto;gap:6px;text-align:left;cursor:pointer}.dashboard-focus-item:hover{border-color:var(--vd-brand);transform:translateY(-1px)}.dashboard-focus-item strong{font-size:18px}.dashboard-focus-item span{font-size:10px;line-height:1.35}.dashboard-focus-item em{font-size:9px;font-style:normal;color:var(--vd-brand)}.dashboard-focus-item.tone-ready{background:color-mix(in srgb,var(--vd-success-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-failed{background:color-mix(in srgb,var(--vd-danger-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-uploads{background:color-mix(in srgb,var(--vd-info-soft) 72%,var(--vd-surface))} +.dashboard-lower-grid{display:grid;grid-template-columns:minmax(0,1fr) 255px;gap:12px}.dashboard-table-wrap{overflow:auto}.dashboard-table{width:100%;border-collapse:collapse;font-size:9px}.dashboard-table th,.dashboard-table td{padding:8px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.dashboard-table th{color:var(--vd-ink-muted);font-weight:600}.dashboard-table tbody tr{cursor:pointer}.dashboard-table tbody tr:hover{background:var(--vd-surface-muted)}.dashboard-product{display:flex;align-items:center;gap:8px;min-width:210px}.dashboard-product img,.dashboard-product>span{width:34px;height:34px;object-fit:contain;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted)}.dashboard-product>div{display:grid;gap:2px}.dashboard-product strong{font-size:9px}.dashboard-product small{font-size:8px;color:var(--vd-ink-muted)}.dashboard-status{padding:3px 6px;background:var(--vd-success-soft);color:var(--vd-success);font-size:8px}.dashboard-actions{display:grid;gap:7px;padding:0 12px 12px}.dashboard-actions button{min-height:34px;padding:0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:10px;font-weight:700;text-align:left;cursor:pointer}.dashboard-actions button:hover{border-color:var(--vd-brand);color:var(--vd-brand)}.dashboard-actions button.primary{border-color:var(--vd-brand);background:var(--vd-brand);color:#fff;text-align:center}.dashboard-quality{display:grid;grid-template-columns:58px minmax(0,1fr);gap:11px;align-items:center;padding:0 13px 12px}.dashboard-quality .confidence-ring{width:56px;height:56px}.dashboard-quality>div:last-child{display:grid;gap:3px}.dashboard-quality strong{font-size:11px;color:var(--vd-success)}.dashboard-quality p{font-size:9px;line-height:1.4;color:var(--vd-ink-muted)}.dashboard-card>.vd-button.full{width:calc(100% - 26px);margin:0 13px 13px}.dashboard-feed{display:grid;padding:0 12px 12px}.dashboard-feed button{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px;align-items:start;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.dashboard-feed button:first-child{border-top:0}.dashboard-feed-icon{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:9px;font-weight:800}.dashboard-feed-icon.tone-danger,.dashboard-feed-icon.tone-failed{background:var(--vd-danger-soft);color:var(--vd-danger)}.dashboard-feed button>span:last-child{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 6px}.dashboard-feed strong{font-size:9px;line-height:1.35}.dashboard-feed small{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.dashboard-system p{padding:0 13px 12px;font-size:9px;line-height:1.45;color:var(--vd-ink-muted)}.dashboard-empty{padding:18px!important;text-align:center;color:var(--vd-ink-muted)!important;font-size:10px!important} + +/* Studio Flow V2 */ +.today-summary-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:12px}.today-summary-grid>button{min-height:78px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);color:var(--vd-ink);display:grid;grid-template-columns:28px 42px minmax(0,1fr);grid-template-rows:auto auto;align-items:center;gap:2px 9px;text-align:left;cursor:pointer}.today-summary-grid>button:hover{border-color:var(--vd-brand)}.today-summary-grid>button>span{grid-row:1/3;width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:15px}.today-summary-grid>button:last-child>span{background:var(--vd-danger-soft);color:var(--vd-danger)}.today-summary-grid strong{grid-row:1/3;font-size:20px}.today-summary-grid small{font-size:10px;font-weight:700}.today-summary-grid em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.today-filterbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.today-filter-tabs{display:flex;align-items:center;gap:5px}.today-filter-tabs button,.today-filterbar select{height:32px;padding:0 12px;border:1px solid transparent;border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted);color:var(--vd-ink-secondary);font:inherit;font-size:10px;cursor:pointer}.today-filter-tabs button.active{border-color:var(--vd-brand);background:var(--vd-surface-raised);color:var(--vd-brand)}.today-filterbar select{border-color:var(--vd-line);background:var(--vd-surface-raised)}.studio-layout-v2{grid-template-columns:minmax(0,1fr) 300px}.today-ready-panel{margin-bottom:12px}.today-ready-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:0 12px 12px}.today-product-card{min-width:0;padding:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);overflow:hidden;background:var(--vd-surface-raised);color:var(--vd-ink);text-align:left;cursor:pointer}.today-product-card:hover,.today-product-card.selected{border-color:var(--vd-brand);box-shadow:0 0 0 1px var(--vd-brand)}.today-product-media{height:135px;position:relative;display:grid;place-items:center;background:linear-gradient(145deg,var(--vd-surface),var(--vd-surface-muted));overflow:hidden}.today-product-media img{width:100%;height:100%;object-fit:contain;padding:8px}.today-product-media>b{position:absolute;right:6px;top:6px;padding:3px 5px;background:color-mix(in srgb,var(--vd-surface-raised) 92%,transparent);font-size:8px}.today-product-copy{display:grid;gap:4px;padding:9px}.today-product-copy>strong{font-size:10px;line-height:1.35;min-height:27px}.today-product-copy>span{font-size:8px;color:var(--vd-ink-muted)}.today-product-copy>div{display:flex;align-items:center;justify-content:space-between;gap:8px}.today-product-copy em{font-size:11px;font-style:normal;font-weight:750}.today-product-copy small{font-size:8px;color:var(--vd-success)}.today-middle-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(235px,.65fr);gap:12px;margin-bottom:12px}.today-table-wrap{overflow:auto}.today-table{width:100%;border-collapse:collapse;font-size:9px}.today-table th,.today-table td{padding:7px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.today-table th{color:var(--vd-ink-muted);font-weight:600}.today-table td:first-child button{display:flex;align-items:center;gap:8px;border:0;background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-table td:first-child button>span{width:34px;height:28px;display:grid;place-items:center}.today-table td:first-child img{width:34px;height:28px;object-fit:contain}.today-table td:first-child div{display:grid;gap:2px}.today-table td:first-child strong{font-size:9px}.today-table td:first-child small{font-size:8px;color:var(--vd-ink-muted)}.today-status{padding:3px 6px;background:var(--vd-warning-soft);color:var(--vd-warning);font-size:8px}.attention-list.compact{padding:0 10px}.attention-list.compact .attention-item{width:100%;display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:8px;align-items:center;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.attention-list.compact .attention-item:first-child{border-top:0}.attention-symbol{width:18px;height:18px;display:grid;place-items:center;color:var(--vd-brand);font-size:10px}.attention-copy{display:grid;gap:2px}.attention-copy strong{font-size:9px}.attention-copy small{font-size:8px;color:var(--vd-ink-muted)}.attention-list.compact .attention-item>b{font-size:9px}.today-panel-footer{display:block;margin:8px auto 10px}.today-activity-panel{margin-bottom:0}.today-activity-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:0 12px 12px}.today-activity-grid>div{min-width:0}.today-activity-grid h4{margin-bottom:6px;font-size:9px}.today-activity-grid .activity-item{width:100%;display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-activity-grid .activity-item:first-child{border-top:0}.today-activity-grid .activity-copy{font-size:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.today-activity-grid .activity-time{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.inspector-photo-count{position:absolute;left:8px;bottom:8px;padding:4px 6px;background:rgba(0,0,0,.66);color:#fff;font-size:8px}.inspector-title-row{display:flex;align-items:start;justify-content:space-between;gap:8px}.inspector-title-row>div{min-width:0}.inspector-thumbs>span{display:grid;place-items:center;border:1px solid var(--vd-line);font-size:9px;color:var(--vd-ink-muted)} +.publish-loading-state{min-height:250px;display:grid;place-items:center;align-content:center;gap:10px;color:var(--vd-ink-muted)}.publish-loading-state>span{width:28px;height:28px;border:2px solid var(--vd-line);border-top-color:var(--vd-brand);border-radius:50%;animation:spin .8s linear infinite}.publish-loading-state strong{font-size:11px}@keyframes spin{to{transform:rotate(360deg)}} + +/* Width and device hardening */ +@media(max-width:1450px){.dashboard-kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1.35fr 1fr}.dashboard-chart-grid>.dashboard-card:last-child{grid-column:1/-1}.dashboard-category-chart{grid-template-columns:180px minmax(0,1fr)}.today-ready-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n+4){display:none}} +@media(max-width:1180px){.dashboard-layout{grid-template-columns:1fr}.dashboard-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr 1fr}.dashboard-line-card{grid-column:1/-1}.dashboard-lower-grid{grid-template-columns:1fr}.studio-layout-v2{grid-template-columns:1fr}.studio-layout-v2 .studio-inspector{position:relative;top:auto;max-height:none;display:none}.studio-layout-v2 .studio-inspector:has(.inspector-content:not(.hidden)){display:block}.today-middle-grid{grid-template-columns:1fr}.today-ready-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n){display:block}} +@media(max-width:900px){.dashboard-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr}.dashboard-chart-grid>.dashboard-card{grid-column:auto!important}.dashboard-focus-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-rail{grid-template-columns:1fr}.today-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-summary-grid>button:last-child{grid-column:1/-1}.today-ready-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-filterbar{align-items:stretch;flex-direction:column}.today-filter-tabs{overflow:auto;padding-bottom:2px}.today-filter-tabs button{flex:0 0 auto}.today-activity-grid{grid-template-columns:1fr}.dashboard-heading{align-items:flex-start}.dashboard-controls{width:100%}.dashboard-controls select{flex:1}} +@media(max-width:620px){.dashboard-kpis{grid-template-columns:1fr}.dashboard-kpi{min-height:105px}.dashboard-focus-grid{grid-template-columns:1fr}.dashboard-category-chart{grid-template-columns:1fr;justify-items:center}.dashboard-category-legend{width:100%}.dashboard-line-card .dashboard-chart{height:220px;overflow-x:auto}.dashboard-line-card svg{min-width:620px}.dashboard-lower-grid{display:block}.dashboard-actions-card{margin-top:12px}.today-summary-grid{grid-template-columns:1fr}.today-summary-grid>button:last-child{grid-column:auto}.today-ready-grid{grid-template-columns:1fr}.today-product-card{display:grid;grid-template-columns:115px minmax(0,1fr)}.today-product-media{height:108px}.today-table{min-width:650px}.today-activity-grid{padding-inline:10px}.dashboard-controls{flex-direction:column;align-items:stretch}.dashboard-controls .vd-button{width:100%}} + +/* ========================================================================== + Slice 11 — dashboard-first, fluid analytics & Studio Flow image hardening + ========================================================================== */ +.dashboard-page, +.dashboard-layout, +.dashboard-main, +.dashboard-chart-grid, +.dashboard-kpis, +.today-ready-grid, +.today-product-card { min-width: 0; } + +/* KPI cards determine their own column count from the actually available width. */ +.dashboard-kpis { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); + align-items: stretch; +} +.dashboard-kpi { + min-height: 132px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + grid-template-rows: minmax(62px, auto) 32px; + column-gap: 10px; + row-gap: 8px; + align-content: start; + padding: 12px; +} +.dashboard-kpi-icon { + grid-column: 1; + grid-row: 1; + align-self: start; + flex: none; +} +.dashboard-kpi > div { + grid-column: 2; + grid-row: 1; + min-width: 0; + margin: 0; + align-content: start; +} +.dashboard-kpi small, +.dashboard-kpi em { + overflow-wrap: anywhere; +} +.dashboard-kpi strong { + font-size: clamp(16px, 1.2vw, 20px); + line-height: 1.15; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dashboard-kpi svg { + position: static; + grid-column: 1 / -1; + grid-row: 2; + align-self: end; + width: 100%; + height: 30px; + max-width: none; + overflow: visible; +} + +.dashboard-chart-grid { + grid-template-columns: repeat(12, minmax(0, 1fr)); +} +.dashboard-line-card { grid-column: span 5; } +.dashboard-chart-grid > .dashboard-card:nth-child(2) { grid-column: span 4; } +.dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: span 3; } +.dashboard-chart, +.dashboard-bar-chart, +.dashboard-category-chart { min-width: 0; width: 100%; } +.dashboard-chart { overflow: hidden; } +.dashboard-chart svg { display: block; width: 100%; max-width: 100%; } +.dashboard-bar-chart { gap: clamp(5px, 1vw, 12px); } +.dashboard-bar-item { min-width: 42px; } +.dashboard-category-chart { grid-template-columns: minmax(112px, 42%) minmax(0, 1fr); } +.dashboard-donut { width: min(100%, 150px); } + +/* Ready products always retain the complete product photo. */ +.today-ready-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); + align-items: stretch; +} +.today-ready-grid > .today-product-card:nth-child(n) { display: grid; } +.today-product-card { + grid-template-rows: auto 1fr; + height: 100%; +} +.today-product-media { + width: 100%; + height: auto; + min-height: 150px; + aspect-ratio: 4 / 3; + padding: 9px; + overflow: hidden; + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.today-product-media img { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + padding: 0; + margin: auto; + object-fit: contain; + object-position: center; +} +.today-product-copy { align-content: start; } +.today-product-copy > strong { + min-height: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +@media (max-width: 1550px) { + .dashboard-chart-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-line-card { grid-column: 1 / -1; } + .dashboard-chart-grid > .dashboard-card:nth-child(2), + .dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: auto; } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +} + +@media (max-width: 1180px) { + .dashboard-chart-grid { grid-template-columns: 1fr; } + .dashboard-chart-grid > .dashboard-card { grid-column: auto !important; } + .dashboard-category-chart { grid-template-columns: minmax(130px, 32%) minmax(0, 1fr); } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); } +} + +@media (max-width: 760px) { + .dashboard-kpis { grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); } + .dashboard-kpi { min-height: 126px; } + .dashboard-category-chart { grid-template-columns: 1fr; justify-items: center; } + .dashboard-category-legend { width: 100%; } + .dashboard-line-card .dashboard-chart { height: 220px; overflow: hidden; } + .dashboard-line-card svg { min-width: 0 !important; width: 100% !important; } + .today-ready-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; padding-inline: 9px; } + .today-product-media { min-height: 125px; padding: 7px; } +} + +@media (max-width: 520px) { + .dashboard-kpis { grid-template-columns: 1fr; } + .dashboard-kpi { + min-height: 112px; + grid-template-columns: 26px minmax(0, 1fr) minmax(90px, 34%); + grid-template-rows: auto; + align-items: center; + } + .dashboard-kpi-icon { grid-column: 1; grid-row: 1; } + .dashboard-kpi > div { grid-column: 2; grid-row: 1; } + .dashboard-kpi svg { grid-column: 3; grid-row: 1; height: 44px; } + .today-ready-grid { grid-template-columns: 1fr; } + .today-product-card { + display: grid; + grid-template-columns: minmax(112px, 36%) minmax(0, 1fr); + grid-template-rows: 1fr; + } + .today-product-media { + height: 100%; + min-height: 125px; + aspect-ratio: auto; + } +} +.today-image-placeholder { + display: grid; + place-items: center; + width: 100%; + height: 100%; + color: var(--vd-ink-muted); + font-size: 9px; + text-align: center; +} +@media (max-width: 760px) and (min-width: 521px) { + .today-product-card { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } +} + +/* Vendoo Brand Asset Integration */ +.sidebar-logo{width:100%;display:flex;align-items:center;justify-content:flex-start;min-width:0} +.brand-logo-full{display:block;width:124px;max-width:100%;height:auto;object-fit:contain;object-position:left center} +.brand-logo-icon{display:none;width:34px;height:34px;object-fit:contain} +@media(max-width:1024px){ + .sidebar-logo{justify-content:center} + .brand-logo-full{display:none} + .brand-logo-icon{display:block} +} + +/* --- AI Model Photos --- */ +.ai-model-card { + margin-top: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + display: grid; + gap: 12px; +} + +.ai-model-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.ai-model-card-head > div:first-child { + display: flex; + gap: 12px; +} + +.ai-model-card h4 { + margin: 0 0 4px; + font-size: 15px; +} + +.ai-model-card p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.ai-model-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} + +.ai-model-controls label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.ai-model-controls .small-btn { + height: 40px; + justify-content: center; +} + +.ai-model-status { + font-size: 12px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + color: var(--text); +} + +.ai-model-status.is-working { border-color: color-mix(in srgb, var(--vd-warning) 38%, var(--line)); } +.ai-model-status.is-success { border-color: color-mix(in srgb, var(--vd-success) 42%, var(--line)); } +.ai-model-status.is-danger { border-color: color-mix(in srgb, var(--vd-danger) 42%, var(--line)); } + +.ai-model-grid { + display: grid; + gap: 12px; +} + +.ai-model-grid-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.ai-model-grid-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.ai-model-shot-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 8px; + background: var(--surface); + display: grid; + gap: 8px; +} + +.ai-model-shot-card img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 5px; + border: 1px solid var(--line); + background: #fff; +} + +.ai-model-shot-copy { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.ai-model-shot-copy strong, +.ai-model-shot-copy span { + font-size: 12px; +} + +.detail-ai-model-card { + margin-top: 14px; +} + +@media (max-width: 980px) { + .ai-model-controls { + grid-template-columns: 1fr; + } + + .ai-model-grid-head { + flex-direction: column; + align-items: stretch; + } +} + +/* Slice 15: AI provider settings */ +.settings-form { max-width: 920px; } +.ai-settings-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 16px; + background: var(--panel); +} +.ai-settings-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 14px; +} +.ai-settings-head h3 { + margin: 0 0 4px; + font-size: 16px; +} +.ai-settings-head p { + margin: 0; + color: var(--text-muted); + font-size: 12px; +} +.ai-settings-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 14px; +} +.ai-settings-grid > div { min-width: 0; } +.setting-inline-checks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin-bottom: 14px; +} +.setting-inline-checks label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.setting-inline-checks input { margin: 0; } +.inline-input-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.compact-inline { + white-space: nowrap; + height: 40px; +} +.ai-models-status { + font-size: 12px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 10px 12px; + color: var(--text); +} +.openrouter-models-preview { + margin-top: 12px; + display: grid; + gap: 10px; +} +.openrouter-model-card { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 12px; + display: grid; + gap: 8px; +} +.openrouter-model-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} +.openrouter-model-top strong { + display: block; + font-size: 13px; +} +.openrouter-model-top span { + display: block; + color: var(--text-muted); + font-size: 12px; + margin-top: 2px; +} +.openrouter-model-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.openrouter-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 5px; + font-size: 11px; + border: 1px solid var(--line); + background: var(--panel); +} +.openrouter-badge.is-free { + border-color: color-mix(in srgb, var(--success) 45%, var(--line)); +} +.openrouter-model-description { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} +@media (max-width: 960px) { + .ai-settings-grid, + .setting-inline-checks { + grid-template-columns: 1fr; + } + .inline-input-actions { + grid-template-columns: 1fr; + } +} + + +.ai-generation-history-wrap { + display: grid; + gap: 10px; +} +.ai-generation-history-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.ai-generation-history-head strong, +.ai-generation-history-head span, +.ai-generation-empty { + font-size: 12px; +} +.ai-generation-history-head span, +.ai-generation-empty { color: var(--muted); } +.ai-generation-history { + display: grid; + gap: 10px; +} +.ai-generation-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 10px; + background: var(--surface); + display: grid; + gap: 10px; +} +.ai-generation-card header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.ai-generation-card header strong, +.ai-generation-card header span { + font-size: 12px; +} +.ai-generation-mini-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.ai-generation-thumb { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + padding: 0; + overflow: hidden; + cursor: pointer; +} +.ai-generation-thumb img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} + +/* Slice 17: robust mobile navigation */ +.sidebar-close { + display: none; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink); + cursor: pointer; +} + +@media (max-width: 768px) { + body.mobile-nav-open { + overflow: hidden; + touch-action: none; + } + + .sidebar { + display: flex; + width: min(86vw, 310px) !important; + max-width: 310px; + transform: translate3d(-105%, 0, 0); + transition: transform .22s ease; + z-index: 320; + box-shadow: none; + overflow-y: auto; + visibility: visible; + pointer-events: none; + } + + .sidebar.open { + transform: translate3d(0, 0, 0); + box-shadow: 10px 0 34px rgba(23, 23, 20, .24); + pointer-events: auto; + } + + .sidebar-header { + min-height: var(--vd-topbar-height); + height: auto; + padding: 10px 12px 10px 16px; + justify-content: space-between !important; + gap: 12px; + } + + .sidebar-logo { + justify-content: flex-start !important; + min-width: 0; + } + + .brand-logo-full { + display: block !important; + max-width: 150px; + height: 34px; + object-fit: contain; + object-position: left center; + } + + .brand-logo-icon { + display: none !important; + } + + .sidebar-close { + display: inline-flex; + flex: 0 0 auto; + } + + .sidebar-nav { + padding: 12px 10px 8px; + } + + .nav-item, + .nav-item-quiet { + justify-content: flex-start !important; + min-height: 44px; + padding: 0 12px !important; + gap: 12px; + font-size: 14px; + } + + .nav-item span, + .nav-group-label, + .sidebar-footer .theme-toggle span, + .sidebar-user-info { + display: block !important; + } + + .nav-item .nav-badge { + display: inline-flex !important; + flex: 0 0 auto; + margin-left: auto; + } + + .sidebar-footer .theme-toggle { + justify-content: flex-start !important; + padding: 0 10px !important; + } + + .sidebar-user { + text-align: left; + } + + .sidebar-overlay { + position: fixed; + inset: 0; + z-index: 310; + display: block; + opacity: 0; + visibility: hidden; + pointer-events: none; + background: rgba(20, 20, 18, .46); + backdrop-filter: blur(2px); + transition: opacity .18s ease, visibility .18s ease; + } + + .sidebar-overlay.show { + display: block; + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + .sidebar-toggle { + display: inline-flex !important; + width: 38px; + height: 38px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface); + position: relative; + z-index: 2; + } +} diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/server.mjs b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/server.mjs new file mode 100644 index 0000000..dbf40e0 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/server.mjs @@ -0,0 +1,1704 @@ +import dotenv from 'dotenv'; +import { fileURLToPath as toPath } from 'url'; +import { dirname as dir, join as pjoin } from 'path'; +dotenv.config({ path: pjoin(dir(toPath(import.meta.url)), '.env') }); + +import express from 'express'; +import multer from 'multer'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import sharp from 'sharp'; +import { join, dirname, extname, basename, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID, randomBytes, createHash } from 'crypto'; +import { networkInterfaces } from 'os'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs'; +import { + createUser, getUser, getUserByEmail, getUsers, updateUser, deleteUser, + createSession, validateSession, destroySession, destroySessionById, destroyUserSessions, + getUserSessions, getAllActiveSessions, + createAuthToken, validateAuthToken, + checkRateLimit, generateCsrfToken, + auditLog, getAuditLog, getAuditLogForUser, + recordLogin, getLoginHistory, + isSetupMode, getUserCount, + verifyPassword, setPassword, + cleanExpiredSessions, +} from './lib/auth.mjs'; +import { initMailer, sendMagicLink, sendCredentials, isMailerConfigured, reinitMailer, getMailerConfig, testMailer } from './lib/mailer.mjs'; +import { + createListing, getListing, getListings, updateListing, deleteListing, + softDeleteListing, restoreListing, getDeletedListings, permanentlyDeleteListing, cleanupTrash, getTrashCount, + duplicateListing, + getSettings, updateSettings, + createTemplate, getTemplate, getTemplates, updateTemplate, deleteTemplate, + getListingsForExport, + createPublishLog, updatePublishLog, getPublishLogs, getPublishStatus, + addToEbayQueue, getEbayQueue, updateEbayQueue, removeFromEbayQueue, clearEbayQueue, + schedulePublish, getScheduledPublishes, getDueScheduled, updateScheduledPublish, removeScheduledPublish, + getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation, + getDashboardWorkflow, getDashboardAnalytics, + createMobileUploadSession, getMobileUploadSessionById, getMobileUploadSessionByTokenHash, + appendMobileUploadFiles, updateMobileUploadSession, deleteMobileUploadSession, cleanupMobileUploadSessions, + createAiModelGeneration, updateAiModelGeneration, createAiModelPhoto, getAiModelPhotosForListing, + getAiModelGenerationsForListing, getAiModelPhotosForGeneration, getAiModelJob, +} from './lib/db.mjs'; +import * as etsyApi from './lib/etsy-api.mjs'; +import * as ebayApi from './lib/ebay-api.mjs'; +import { wrapInTemplate, getHtmlTemplates, sanitizeRichHtml } from './lib/html-templates.mjs'; +import { prepareForAI, removeBackground, addWatermark } from './lib/images.mjs'; +import { generate, getProviders } from './lib/ai-router.mjs'; +import { calculateFees, getAllFees } from './lib/fees.mjs'; +import { createZip } from './lib/zip.mjs'; +import { getCategories, getCategoriesFlat, matchCategories, getAllPlatformIds } from './lib/categories.mjs'; +import { precheckAiModelPhotos, generateAiModelPhotos, fetchOpenRouterImageModels, getOpenRouterImageDiagnostics } from './lib/ai-model-photos.mjs'; +import { enqueueAiModelJob, getAiModelJobWithRelations, startAiModelJobLoop } from './lib/ai-model-jobs.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = express(); +const PORT = process.env.PORT || 8124; + +startAiModelJobLoop(); + + +function htmlToPlainText(html) { + return String(html || '') + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function normalizeListingPayload(payload = {}) { + const normalized = { ...payload }; + if (normalized.description_html !== undefined) { + normalized.description_html = sanitizeRichHtml(normalized.description_html); + if (normalized.description === undefined) normalized.description = htmlToPlainText(normalized.description_html); + } + if (normalized.description !== undefined) normalized.description = String(normalized.description || '').trim(); + if (normalized.tags !== undefined && !Array.isArray(normalized.tags)) { + normalized.tags = String(normalized.tags || '').split(',').map(tag => tag.trim()).filter(Boolean); + } + if (normalized.photos !== undefined && !Array.isArray(normalized.photos)) normalized.photos = []; + return normalized; +} + + +function hashMobileUploadToken(token) { + return createHash('sha256').update(String(token || '')).digest('hex'); +} + +function isMobileUploadSessionUsable(session) { + if (!session || session.status !== 'active') return false; + const expiresAt = Date.parse(String(session.expires_at || '').replace(' ', 'T') + 'Z'); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +} + +function getLanIpv4Addresses() { + const addresses = []; + const interfaces = networkInterfaces(); + for (const [name, entries] of Object.entries(interfaces)) { + for (const entry of entries || []) { + const family = typeof entry.family === 'string' ? entry.family : (entry.family === 4 ? 'IPv4' : String(entry.family)); + if (family !== 'IPv4' || entry.internal) continue; + const address = String(entry.address || '').trim(); + if (!address || address.startsWith('169.254.')) continue; + const isPrivate = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(address); + const lowerName = String(name || '').toLowerCase(); + const isVirtual = /(virtual|vmware|hyper-v|vethernet|wsl|docker|vpn|tailscale|zerotier|loopback|bluetooth)/i.test(lowerName); + const isPrimaryAdapter = /(ethernet|wlan|wi-fi|wifi|lan)/i.test(lowerName); + const score = (isPrivate ? 100 : 0) + (isPrimaryAdapter ? 30 : 0) - (isVirtual ? 80 : 0); + addresses.push({ name, address, isPrivate, isVirtual, score }); + } + } + addresses.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + return addresses; +} + +function resolvePublicBaseUrl(req) { + const configured = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, ''); + if (configured) { + try { + const configuredUrl = new URL(configured); + const configuredHost = configuredUrl.hostname.toLowerCase(); + if (!['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(configuredHost)) return configured; + } catch {} + } + + const forwardedProto = String(req.get('x-forwarded-proto') || '').split(',')[0].trim(); + const protocol = forwardedProto || req.protocol || 'http'; + const rawHost = String(req.get('x-forwarded-host') || req.get('host') || `localhost:${PORT}`).split(',')[0].trim(); + const hostOnly = rawHost.replace(/^\[/, '').replace(/\](:\d+)?$/, '').replace(/:\d+$/, '').toLowerCase(); + const isLoopback = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostOnly); + if (!isLoopback) return `${protocol}://${rawHost}`.replace(/\/$/, ''); + + const lan = getLanIpv4Addresses()[0]; + const portMatch = rawHost.match(/:(\d+)$/); + const port = portMatch?.[1] || String(PORT); + if (lan?.address) return `${protocol}://${lan.address}:${port}`; + return `${protocol}://${rawHost}`.replace(/\/$/, ''); +} + +app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); +app.use(express.json()); +app.use(cookieParser()); +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Token'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +// Init mailer — check DB settings first, fallback to .env +function initMailerFromDbOrEnv() { + try { + const settings = getSettings(); + if (settings.smtp_host && settings.smtp_user && settings.smtp_pass) { + const ok = reinitMailer({ + host: settings.smtp_host, + port: settings.smtp_port || '587', + user: settings.smtp_user, + pass: settings.smtp_pass, + }); + if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from; + if (ok) return; + } + } catch {} + initMailer(); +} +initMailerFromDbOrEnv(); + +// --- Auth Middleware --- +function getClientIp(req) { + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || ''; +} + +function requireAuth(req, res, next) { + // Setup mode: skip auth + if (isSetupMode()) return next(); + + const token = req.cookies?.auth_token; + const session = validateSession(token); + if (!session) { + if (req.originalUrl.startsWith('/api/')) return res.status(401).json({ error: 'Nicht eingeloggt' }); + return res.redirect('/login.html'); + } + req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, avatar_color: session.avatar_color }; + next(); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (isSetupMode()) return next(); + if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Keine Berechtigung' }); + next(); + }; +} + +function csrfProtect(req, res, next) { + if (isSetupMode()) return next(); + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const token = req.headers['x-csrf-token'] || req.body?._csrf; + const cookie = req.cookies?.csrf_token; + if (!token || !cookie || token !== cookie) { + return res.status(403).json({ error: 'Ungültiger CSRF-Token' }); + } + next(); +} + +// --- Public routes (no auth) --- +app.use('/login.html', express.static(join(__dirname, 'public', 'login.html'))); + +app.get('/auth/setup-check', (_req, res) => { + res.json({ setupMode: isSetupMode(), mailConfigured: isMailerConfigured() }); +}); + +// Setup: create first admin with password +app.post('/auth/setup', (req, res) => { + if (!isSetupMode()) return res.status(400).json({ error: 'Setup bereits abgeschlossen' }); + const { email, name, password } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + if (!password || password.length < 6) return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + + const ip = getClientIp(req); + try { + const user = createUser(email, name || email.split('@')[0], 'admin', null, password); + auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via setup', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, { httpOnly: true, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + res.cookie('csrf_token', csrf, { httpOnly: false, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + return res.json({ ok: true, redirect: '/' }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Login with email + password +app.post('/auth/login', (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' }); + + const ip = getClientIp(req); + const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000); + if (!rl.allowed) return res.status(429).json({ error: `Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.` }); + + const user = getUserByEmail(email); + if (!user || !verifyPassword(password, user.password_hash)) { + if (user) recordLogin(user.id, ip, req.headers['user-agent'] || '', false); + auditLog(user?.id || null, email, 'login.failed', null, null, null, ip); + return res.status(401).json({ error: 'E-Mail oder Passwort falsch' }); + } + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via password', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, { httpOnly: true, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + res.cookie('csrf_token', csrf, { httpOnly: false, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + res.json({ ok: true, redirect: '/' }); +}); + +// Invite: admin sends invite link (magic link only for invitations) +app.post('/auth/invite', requireAuth, requireRole('admin'), async (req, res) => { + const { email, name, role } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const baseUrl = `${req.protocol}://${req.get('host')}`; + try { + const { token } = createAuthToken(email, 'invite', role || 'editor', req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${role || 'editor'}`, getClientIp(req)); + res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Magic link: only for invite — shows password-set form +app.get('/auth/magic', (req, res) => { + const { token } = req.query; + if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben')); + res.redirect(`/login.html?invite=${encodeURIComponent(token)}`); +}); + +// Accept invite: set password and activate account +app.post('/auth/accept-invite', (req, res) => { + const { token, password, name } = req.body; + if (!token) return res.status(400).json({ error: 'Kein Token angegeben' }); + if (!password || password.length < 6) return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + + try { + const authToken = validateAuthToken(token); + if (!authToken) return res.status(400).json({ error: 'Link ungültig oder abgelaufen' }); + + const ip = getClientIp(req); + const ua = req.headers['user-agent'] || ''; + + let user = getUserByEmail(authToken.email); + if (!user) { + user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password); + auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip); + } else { + setPassword(user.id, password); + if (name) updateUser(user.id, { name }); + } + + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + + const session = createSession(user.id, ip, ua); + recordLogin(user.id, ip, ua, true); + auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, { httpOnly: true, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + res.cookie('csrf_token', csrf, { httpOnly: false, sameSite: 'lax', maxAge: 3 * 24 * 60 * 60 * 1000, path: '/' }); + res.json({ ok: true, redirect: '/' }); + } catch (err) { + console.error('Accept invite error:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/auth/logout', (req, res) => { + const token = req.cookies?.auth_token; + if (token) { + const session = validateSession(token); + if (session) { + auditLog(session.user_id, session.email, 'logout', null, null, null, getClientIp(req)); + } + destroySession(token); + } + res.clearCookie('auth_token'); + res.clearCookie('csrf_token'); + res.json({ ok: true }); +}); + + +// --- Mobile photo upload (public token page, no account login required) --- +app.get('/mobile-upload/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).sendFile(join(__dirname, 'public', 'mobile-upload-expired.html')); + res.sendFile(join(__dirname, 'public', 'mobile-upload.html')); +}); + +app.get('/mobile-upload-api/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + res.json({ + id: session.id, + context: session.context, + listing_id: session.listing_id, + files: session.files || [], + remaining: Math.max(0, 10 - (session.files || []).length), + expires_at: session.expires_at, + }); +}); + +// --- Protected routes below --- +app.use('/api', requireAuth); +app.use('/auth/ebay', requireAuth); +app.use('/auth/etsy', requireAuth); + +// CSRF for state-changing API calls +app.use('/api', csrfProtect); + +// Current user info +app.get('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ setupMode: true, role: 'admin' }); + const user = getUser(req.user.id); + const initials = (user.name || user.email).split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); + res.json({ + id: user.id, email: user.email, name: user.name, role: user.role, + avatar_color: user.avatar_color, initials, + csrf: req.cookies?.csrf_token, + }); +}); + +app.put('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + const { name } = req.body; + if (name !== undefined) updateUser(req.user.id, { name }); + auditLog(req.user.id, req.user.email, 'user.profile_updated', 'user', String(req.user.id), null, getClientIp(req)); + res.json(getUser(req.user.id)); +}); + +// --- Admin: User Management --- +app.get('/api/admin/users', requireRole('admin'), (_req, res) => { + res.json(getUsers()); +}); + +app.post('/api/admin/users/invite', requireRole('admin'), async (req, res) => { + const { email, name, role, method } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const userRole = role === 'admin' ? 'admin' : 'editor'; + + const existing = getUserByEmail(email); + if (existing) return res.status(400).json({ error: 'User existiert bereits' }); + + const baseUrl = `${req.protocol}://${req.get('host')}`; + const ip = getClientIp(req); + + try { + if (method === 'auto_password') { + // Generate random password + const password = randomUUID().replace(/-/g, '').substring(0, 12); + const user = createUser(email, name || email.split('@')[0], userRole, req.user.id, password); + const mailResult = await sendCredentials(email, password, name || '', baseUrl); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}, Auto-Passwort`, ip); + res.json({ ok: true, emailSent: mailResult.sent, consoleFallback: mailResult.consoleFallback || false, password }); + } else { + // Magic link (existing behavior) + const { token } = createAuthToken(email, 'invite', userRole, req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}`, ip); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/admin/users/resend-invite', requireRole('admin'), async (req, res) => { + const { user_id } = req.body; + const user = getUser(user_id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + + const { token } = createAuthToken(user.email, 'invite', user.role, req.user.id); + const baseUrl = `${req.protocol}://${req.get('host')}`; + const result = await sendMagicLink(user.email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invite_resent', 'user', String(user.id), null, getClientIp(req)); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); +}); + +app.put('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + if (req.user.id === id && req.body.role && req.body.role !== 'admin') { + return res.status(400).json({ error: 'Du kannst dir selbst nicht die Admin-Rolle entziehen' }); + } + const updated = updateUser(id, req.body); + if (!updated) return res.status(404).json({ error: 'User nicht gefunden' }); + auditLog(req.user.id, req.user.email, 'user.updated', 'user', String(id), JSON.stringify(req.body), getClientIp(req)); + res.json(updated); +}); + +app.delete('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + if (req.user.id === id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + deleteUser(id); + auditLog(req.user.id, req.user.email, 'user.deleted', 'user', String(id), user.email, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Sessions --- +app.get('/api/admin/sessions', requireRole('admin'), (_req, res) => { + res.json(getAllActiveSessions()); +}); + +app.delete('/api/admin/sessions/:id', requireRole('admin'), (req, res) => { + destroySessionById(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/admin/users/:id/sessions', requireRole('admin'), (req, res) => { + destroyUserSessions(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Audit Log --- +app.get('/api/admin/audit', requireRole('admin'), (req, res) => { + const limit = parseInt(req.query.limit) || 100; + const offset = parseInt(req.query.offset) || 0; + res.json(getAuditLog(limit, offset)); +}); + +// --- Admin: Login History --- +app.get('/api/admin/login-history/:userId', requireRole('admin'), (req, res) => { + res.json(getLoginHistory(parseInt(req.params.userId))); +}); + +// --- Admin: SMTP Settings --- +app.get('/api/admin/smtp', requireRole('admin'), (_req, res) => { + const settings = getSettings(); + res.json({ + smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', + smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587', + smtp_user: settings.smtp_user || process.env.SMTP_USER || '', + smtp_pass: (settings.smtp_pass || process.env.SMTP_PASS) ? '••••••••' : '', + smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', + configured: isMailerConfigured(), + source: settings.smtp_host ? 'db' : (process.env.SMTP_HOST ? 'env' : 'none'), + }); +}); + +app.put('/api/admin/smtp', requireRole('admin'), (req, res) => { + const { smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from } = req.body; + const smtpSettings = {}; + if (smtp_host !== undefined) smtpSettings.smtp_host = smtp_host; + if (smtp_port !== undefined) smtpSettings.smtp_port = smtp_port; + if (smtp_user !== undefined) smtpSettings.smtp_user = smtp_user; + if (smtp_pass !== undefined && smtp_pass !== '••••••••') smtpSettings.smtp_pass = smtp_pass; + if (smtp_from !== undefined) smtpSettings.smtp_from = smtp_from; + updateSettings(smtpSettings); + + // Reinitialize mailer with new config + const allSettings = getSettings(); + if (allSettings.smtp_host && allSettings.smtp_user && allSettings.smtp_pass) { + const ok = reinitMailer({ + host: allSettings.smtp_host, + port: allSettings.smtp_port || '587', + user: allSettings.smtp_user, + pass: allSettings.smtp_pass, + }); + if (allSettings.smtp_from) process.env.SMTP_FROM = allSettings.smtp_from; + } + + if (req.user) auditLog(req.user.id, req.user.email, 'smtp.updated', null, null, null, getClientIp(req)); + res.json({ + smtp_host: allSettings.smtp_host || '', + smtp_port: allSettings.smtp_port || '587', + smtp_user: allSettings.smtp_user || '', + smtp_pass: allSettings.smtp_pass ? '••••••••' : '', + smtp_from: allSettings.smtp_from || '', + configured: isMailerConfigured(), + }); +}); + +app.post('/api/admin/smtp/test', requireRole('admin'), async (req, res) => { + try { + if (!isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' }); + const adminEmail = req.user?.email; + if (!adminEmail) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' }); + await testMailer(adminEmail); + auditLog(req.user.id, req.user.email, 'smtp.test_sent', null, null, `an ${adminEmail}`, getClientIp(req)); + res.json({ ok: true, message: `Testmail an ${adminEmail} gesendet` }); + } catch (err) { + res.status(500).json({ error: `SMTP-Fehler: ${err.message}` }); + } +}); + +// --- Admin: Password Reset --- +app.post('/api/admin/users/:id/reset-password', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + const { password } = req.body; + if (!password || password.length < 6) return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + setPassword(id, password); + auditLog(req.user.id, req.user.email, 'user.password_reset', 'user', String(id), `für ${user.email}`, getClientIp(req)); + res.json({ ok: true, message: `Passwort für ${user.email} zurückgesetzt` }); +}); + +// --- Admin: User Login History --- +app.get('/api/admin/users/:id/login-history', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + res.json(getLoginHistory(id, 30)); +}); + +// --- Admin: System Info --- +app.get('/api/admin/system', requireRole('admin'), (_req, res) => { + const users = getUsers(); + const listings = getListings(); + const sessions = getAllActiveSessions(); + + let dbSize = 'unbekannt'; + try { + const dbPath = join(__dirname, 'db', 'vendoo.db'); + if (existsSync(dbPath)) { + const bytes = statSync(dbPath).size; + if (bytes < 1024) dbSize = bytes + ' B'; + else if (bytes < 1024 * 1024) dbSize = (bytes / 1024).toFixed(1) + ' KB'; + else dbSize = (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + } catch {} + + const uptimeSeconds = process.uptime(); + const hours = Math.floor(uptimeSeconds / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + + res.json({ + node_version: process.version, + uptime, + uptime_seconds: Math.floor(uptimeSeconds), + total_users: users.length, + total_listings: listings.length, + active_sessions: sessions.length, + db_size: dbSize, + platform: process.platform, + memory_mb: Math.round(process.memoryUsage().rss / (1024 * 1024)), + }); +}); + +// --- Admin: Session Cleanup --- +app.post('/api/admin/sessions/cleanup', requireRole('admin'), (req, res) => { + try { + cleanExpiredSessions(); + auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, getClientIp(req)); + res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' }); + } catch (err) { + res.json({ ok: false, error: err.message }); + } +}); + +// --- My Sessions --- +app.get('/api/my/sessions', (req, res) => { + if (isSetupMode()) return res.json([]); + res.json(getUserSessions(req.user.id)); +}); + +app.delete('/api/my/sessions/:id', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + destroySessionById(parseInt(req.params.id)); + res.json({ ok: true }); +}); + +// Static files served for everyone — auth check happens via /api/me in frontend +app.use(express.static(join(__dirname, 'public'))); +app.use('/uploads', express.static(join(__dirname, 'uploads'))); + +const storage = multer.diskStorage({ + destination: (req, _file, cb) => { + const platform = req.body?.platform || req.query?.platform || 'allgemein'; + const dir = join(__dirname, 'uploads', platform); + mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + cb(null, `${randomUUID()}${ext}`); + }, +}); +const upload = multer({ + storage, + fileFilter: (_req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']; + const ext = extname(file.originalname).toLowerCase(); + const isImageMime = String(file.mimetype || '').startsWith('image/'); + cb(null, allowed.includes(ext) || isImageMime); + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}); + + + +async function normalizeMobileUploadFile(file) { + const ext = extname(file.originalname || file.filename || '').toLowerCase(); + if (!['.heic', '.heif'].includes(ext) && !/hei[cf]/i.test(file.mimetype || '')) { + return file; + } + + const outputName = `${randomUUID()}.jpg`; + const outputPath = join(dirname(file.path), outputName); + try { + await sharp(file.path).rotate().jpeg({ quality: 90 }).toFile(outputPath); + try { unlinkSync(file.path); } catch {} + return { + ...file, + filename: outputName, + path: outputPath, + mimetype: 'image/jpeg', + originalname: String(file.originalname || 'foto.heic').replace(/\.(heic|heif)$/i, '.jpg'), + }; + } catch (error) { + try { unlinkSync(file.path); } catch {} + throw new Error('HEIC/HEIF konnte auf diesem System nicht in JPG umgewandelt werden. Bitte am Smartphone „Am kompatibelsten“ wählen oder als JPG/PNG hochladen.'); + } +} + + +function validateMobileUploadToken(req, res, next) { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + if ((session.files || []).length >= 10) return res.status(400).json({ error: 'Maximal 10 Bilder pro Upload-Sitzung.' }); + req.mobileUploadSession = session; + next(); +} + +app.post('/mobile-upload-api/:token/photos', validateMobileUploadToken, upload.array('photos', 10), async (req, res) => { + const session = req.mobileUploadSession; + const remaining = Math.max(0, 10 - (session.files || []).length); + const uploadedFiles = req.files || []; + const acceptedFiles = uploadedFiles.slice(0, remaining); + for (const extraFile of uploadedFiles.slice(remaining)) { + try { unlinkSync(extraFile.path); } catch {} + } + + try { + const normalizedFiles = []; + for (const file of acceptedFiles) normalizedFiles.push(await normalizeMobileUploadFile(file)); + const platform = req.body?.platform || req.query?.platform || 'allgemein'; + const filenames = normalizedFiles.map(file => `${platform}/${file.filename}`); + const updated = appendMobileUploadFiles(session.id, filenames); + res.json({ + ok: true, + files: updated?.files || [], + added: filenames.length, + remaining: Math.max(0, 10 - (updated?.files || []).length), + }); + } catch (error) { + for (const file of acceptedFiles) { + try { if (existsSync(file.path)) unlinkSync(file.path); } catch {} + } + res.status(400).json({ error: error.message || 'Bilder konnten nicht verarbeitet werden.' }); + } +}); + +// --- Platforms --- +const platforms = {}; +async function loadPlatforms() { + for (const f of ['vinted', 'ebay-ka', 'ebay-de', 'etsy']) { + const mod = await import(`./platforms/${f}.mjs`); + platforms[mod.default.id] = mod.default; + } +} + +// --- Upload --- +app.post('/api/upload', upload.array('photos', 10), (req, res) => { + const platform = req.body?.platform || req.query?.platform || 'allgemein'; + res.json({ filenames: req.files.map(f => `${platform}/${f.filename}`) }); +}); + + +app.get('/api/network-info', (_req, res) => { + const addresses = getLanIpv4Addresses(); + res.json({ + port: Number(PORT), + addresses, + preferred_url: addresses[0]?.address ? `http://${addresses[0].address}:${PORT}` : `http://localhost:${PORT}`, + public_base_url: String(process.env.PUBLIC_BASE_URL || '').trim() || null, + }); +}); + +app.post('/api/mobile-upload-sessions', (req, res) => { + cleanupMobileUploadSessions(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashMobileUploadToken(token); + const context = ['generator', 'listing'].includes(req.body?.context) ? req.body.context : 'generator'; + const listingId = req.body?.listing_id ? Number(req.body.listing_id) : null; + if (context === 'listing' && listingId && !getListing(listingId)) return res.status(404).json({ error: 'Listing nicht gefunden' }); + const expires = new Date(Date.now() + 20 * 60 * 1000); + const expiresSql = expires.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + const session = createMobileUploadSession({ + token_hash: tokenHash, + user_id: req.user?.id || null, + listing_id: listingId, + context, + expires_at: expiresSql, + }); + const baseUrl = resolvePublicBaseUrl(req); + const url = `${baseUrl}/mobile-upload/${encodeURIComponent(token)}`; + res.json({ + id: session.id, + url, + base_url: baseUrl, + detected_lan_ip: getLanIpv4Addresses()[0]?.address || null, + token, + context, + listing_id: listingId, + files: [], + expires_at: expires.toISOString(), + }); +}); + +app.get('/api/mobile-upload-sessions/:id', (req, res) => { + cleanupMobileUploadSessions(); + const session = getMobileUploadSessionById(Number(req.params.id), req.user?.id || null); + if (!session || !isMobileUploadSessionUsable(session)) return res.status(404).json({ error: 'Upload-Sitzung nicht gefunden oder abgelaufen' }); + res.json({ id: session.id, context: session.context, listing_id: session.listing_id, files: session.files || [], expires_at: session.expires_at, status: session.status }); +}); + +app.delete('/api/mobile-upload-sessions/:id', (req, res) => { + deleteMobileUploadSession(Number(req.params.id), req.user?.id || null); + res.json({ ok: true }); +}); + +// --- Image tools --- +app.post('/api/images/remove-bg', async (req, res) => { + try { + const { filename } = req.body; + const newName = await removeBackground(filename); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/images/watermark', async (req, res) => { + try { + const { filename, text } = req.body; + const newName = await addWatermark(filename, text); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- AI Model Photos --- +app.get('/api/listings/:id/ai-model-generations', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const generations = getAiModelGenerationsForListing(req.params.id).map(item => ({ + ...item, + photos: getAiModelPhotosForGeneration(item.id), + })); + res.json(generations); +}); + +app.get('/api/ai-model-photos/jobs/:id', (req, res) => { + const job = getAiModelJobWithRelations(req.params.id); + if (!job) return res.status(404).json({ error: 'Nicht gefunden' }); + const response = { ...job }; + if (job.generation_id) { + response.photos = getAiModelPhotosForGeneration(job.generation_id); + } + res.json(response); +}); + +app.post('/api/ai-model-photos/jobs', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const precheck = await precheckAiModelPhotos(payload, settings); + if (!precheck.allowed) return res.status(400).json(precheck); + const job = enqueueAiModelJob({ ...payload, settings, preset: precheck.preset, variants: precheck.variants, mode: precheck.mode }); + res.json({ queued: true, job_id: job.id, job, precheck }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/diagnostics', async (_req, res) => { + try { + const diagnostics = await getOpenRouterImageDiagnostics(); + res.json({ + ...diagnostics, + has_api_key: !!process.env.OPENROUTER_API_KEY, + free_only_enabled: String(getSettings().ai_model_photo_openrouter_free_only || '1') !== '0', + }); + } catch (err) { + res.status(500).json({ error: err.message, has_api_key: !!process.env.OPENROUTER_API_KEY }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/models', async (req, res) => { + try { + const settings = getSettings(); + const freeOnly = req.query.free_only !== undefined + ? String(req.query.free_only) !== '0' + : String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const models = await fetchOpenRouterImageModels({ freeOnly }); + res.json({ provider: 'openrouter', free_only: freeOnly, models }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/ai-model-photos/precheck', async (req, res) => { + try { + const settings = getSettings(); + const payload = normalizeListingPayload(req.body || {}); + const result = await precheckAiModelPhotos(payload, settings); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/ai-model-photos/generate', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const result = await generateAiModelPhotos({ ...payload, settings }); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: result.mode || result.precheck.mode || payload.mode || 'model', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || result.mode || payload.mode || 'model', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + }); + res.json({ ...result, generation_id: generation.id, photos, provider: result.provider || null }); + } catch (err) { + if (err.code === 'AI_MODEL_PRECHECK_FAILED') { + return res.status(400).json({ error: err.message, precheck: err.precheck || null }); + } + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/listings/:id/ai-model-photos', (req, res) => { + try { + res.json(getAiModelPhotosForListing(parseInt(req.params.id))); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- Generate --- +app.post('/api/generate', async (req, res) => { + try { + const { photos, platform: platformId, ai_provider, ai_model, language, variants, template_id, seller_notes } = req.body; + if (!photos?.length) return res.status(400).json({ error: 'Keine Fotos angegeben' }); + + const platform = platforms[platformId]; + if (!platform) return res.status(400).json({ error: `Unbekannte Plattform: ${platformId}` }); + + const settings = getSettings(); + const selectedTemplate = template_id ? getTemplate(parseInt(template_id)) : null; + const notesParts = [ + settings.seller_notes, + selectedTemplate?.seller_notes, + typeof seller_notes === 'string' ? seller_notes.slice(0, 1000) : '', + ].map(value => String(value || '').trim()).filter(Boolean); + const sellerNotes = [...new Set(notesParts)].join('\n\n'); + const templateTags = Array.isArray(selectedTemplate?.default_tags) ? selectedTemplate.default_tags : []; + const lang = language || selectedTemplate?.language || settings.default_language || 'de'; + const numVariants = Math.min(parseInt(variants) || 1, 3); + const mergeTags = (values) => { + const sourceTags = Array.isArray(values) + ? values + : typeof values === 'string' + ? values.split(',') + : []; + return [...new Set([...sourceTags, ...templateTags].map(tag => String(tag).trim()).filter(Boolean))]; + }; + + const systemPrompt = platform.buildPrompt(sellerNotes, { language: lang, variants: numVariants }); + const images = await prepareForAI(photos); + const result = await generate(ai_provider || settings.default_ai, images, systemPrompt, ai_model); + + const meta = { + brand: result.brand || null, + category: result.category || null, + size: result.size || null, + color: result.color || null, + condition: result.condition || null, + suggested_price: result.suggested_price || null, + }; + + const baseData = { + platform: platformId, + ai_provider: ai_provider || settings.default_ai, + photos, seller_notes: sellerNotes, language: lang, + }; + + if (numVariants > 1 && result.variants) { + const variantsOut = result.variants.map(v => { + const item = { ...baseData, title: v.title || '', description: v.description || '', tags: mergeTags(v.tags || v.hashtags || []), ...meta, price: meta.suggested_price }; + item.description_html = wrapInTemplate(item, platformId); + return item; + }); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + return res.json({ variants: variantsOut, meta, fees }); + } + + const listing = { ...baseData, title: result.title || '', description: result.description || '', tags: mergeTags(result.tags || result.hashtags || []), ...meta, price: meta.suggested_price }; + const descHtml = wrapInTemplate(listing, platformId); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + res.json({ listing, meta, fees, description_html: descHtml }); + } catch (err) { + console.error('Generate error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Fee calculator --- +app.get('/api/fees', (req, res) => { + const price = parseFloat(req.query.price) || 0; + const platform = req.query.platform; + if (platform) return res.json(calculateFees(platform, price)); + res.json(getAllFees(price)); +}); + +// --- Listings CRUD --- +app.post('/api/listings', (req, res) => { + try { + const listing = createListing(normalizeListingPayload(req.body)); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(listing.id), listing.title, getClientIp(req)); + res.json(listing); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/listings', (req, res) => { + res.json(getListings(req.query.q, req.query.platform, req.query.status)); +}); + +app.get('/api/listings/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(listing); +}); + +app.put('/api/listings/:id', (req, res) => { + const listing = updateListing(req.params.id, normalizeListingPayload(req.body)); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(listing); +}); + +app.delete('/api/listings/:id', (req, res) => { + if (req.user) auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, getClientIp(req)); + softDeleteListing(req.params.id); + res.json({ ok: true }); +}); + +// --- Duplicate --- +app.post('/api/listings/:id/duplicate', (req, res) => { + const copy = duplicateListing(req.params.id); + if (!copy) return res.status(404).json({ error: 'Listing nicht gefunden' }); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(copy.id), copy.title, getClientIp(req)); + res.json(copy); +}); + +// --- Trash --- +app.get('/api/trash', (_req, res) => { + res.json(getDeletedListings()); +}); + +app.get('/api/trash/count', (_req, res) => { + res.json({ count: getTrashCount() }); +}); + +app.post('/api/trash/:id/restore', (req, res) => { + restoreListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash/:id', (req, res) => { + permanentlyDeleteListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash', (req, res) => { + const deleted = getDeletedListings(); + for (const l of deleted) permanentlyDeleteListing(l.id); + if (req.user) auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${deleted.length} Einträge`, getClientIp(req)); + res.json({ ok: true, deleted: deleted.length }); +}); + +// --- Templates CRUD --- +app.get('/api/templates', (_req, res) => res.json(getTemplates())); +app.post('/api/templates', (req, res) => res.json(createTemplate(req.body))); +app.put('/api/templates/:id', (req, res) => { + const t = updateTemplate(req.params.id, req.body); + if (!t) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(t); +}); +app.delete('/api/templates/:id', (req, res) => { + deleteTemplate(req.params.id); + res.json({ ok: true }); +}); + +// --- Inventory / Storage --- +app.get('/api/inventory/locations', (_req, res) => res.json(getStorageLocations())); +app.get('/api/inventory/unassigned', (_req, res) => res.json(getUnassignedListings())); +app.get('/api/inventory/location/:loc', (req, res) => res.json(getListingsByLocation(req.params.loc))); +app.put('/api/inventory/bulk-location', (req, res) => { + const { ids, location } = req.body; + if (!ids?.length) return res.status(400).json({ error: 'ids required' }); + const count = bulkUpdateLocation(ids, location || ''); + res.json({ ok: true, updated: count }); +}); + +// --- CSV Export --- +app.get('/api/export/csv', (req, res) => { + const listings = getListingsForExport(req.query.platform, req.query.status); + const headers = ['ID', 'Plattform', 'Status', 'Titel', 'Beschreibung', 'Tags', 'Marke', 'Kategorie', 'Groesse', 'Farbe', 'Zustand', 'Preis', 'Erstellt']; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + const rows = listings.map(l => [ + l.id, l.platform, l.status || 'active', l.title, l.description, + (l.tags || []).join('; '), l.brand, l.category, l.size, l.color, + l.condition, l.price, l.created_at, + ].map(escape).join(',')); + const csv = [headers.join(','), ...rows].join('\n'); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename=vendoo-export.csv'); + res.send('' + csv); +}); + +// --- Settings --- +app.get('/api/settings', (_req, res) => { + const settings = getSettings(); + settings.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + settings.has_openai_key = !!process.env.OPENAI_API_KEY; + settings.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + settings.has_etsy_key = !!process.env.ETSY_API_KEY; + settings.etsy_connected = etsyApi.isConnected(); + settings.has_ebay_credentials = ebayApi.hasCredentials(); + settings.ebay_connected = ebayApi.isConnected(); + settings.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + settings.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + settings.public_base_url = process.env.PUBLIC_BASE_URL || ''; + res.json(settings); +}); + +app.put('/api/settings', requireRole('admin'), (req, res) => { + const { anthropic_key, openai_key, openrouter_key, etsy_key, ebay_client_id, ebay_client_secret, ebay_ru_name, ebay_sandbox, ollama_url, public_base_url, ...dbSettings } = req.body; + const envPath = join(__dirname, '.env'); + let envContent = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + + function setEnvVar(content, key, value) { + if (value === undefined || value === null) return content; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) return content.replace(regex, line); + return content.trimEnd() + '\n' + line + '\n'; + } + + if (anthropic_key !== undefined) { envContent = setEnvVar(envContent, 'ANTHROPIC_API_KEY', anthropic_key); process.env.ANTHROPIC_API_KEY = anthropic_key; } + if (openai_key !== undefined) { envContent = setEnvVar(envContent, 'OPENAI_API_KEY', openai_key); process.env.OPENAI_API_KEY = openai_key; } + if (openrouter_key !== undefined) { envContent = setEnvVar(envContent, 'OPENROUTER_API_KEY', openrouter_key); process.env.OPENROUTER_API_KEY = openrouter_key; } + if (etsy_key !== undefined) { envContent = setEnvVar(envContent, 'ETSY_API_KEY', etsy_key); process.env.ETSY_API_KEY = etsy_key; } + if (ebay_client_id !== undefined) { envContent = setEnvVar(envContent, 'EBAY_CLIENT_ID', ebay_client_id); process.env.EBAY_CLIENT_ID = ebay_client_id; } + if (ebay_client_secret !== undefined) { envContent = setEnvVar(envContent, 'EBAY_CLIENT_SECRET', ebay_client_secret); process.env.EBAY_CLIENT_SECRET = ebay_client_secret; } + if (ebay_ru_name !== undefined) { envContent = setEnvVar(envContent, 'EBAY_RU_NAME', ebay_ru_name); process.env.EBAY_RU_NAME = ebay_ru_name; } + if (ebay_sandbox !== undefined) { envContent = setEnvVar(envContent, 'EBAY_SANDBOX', ebay_sandbox); process.env.EBAY_SANDBOX = ebay_sandbox; } + if (ollama_url !== undefined) { envContent = setEnvVar(envContent, 'OLLAMA_URL', ollama_url); process.env.OLLAMA_URL = ollama_url; } + if (public_base_url !== undefined) { + const cleanPublicBaseUrl = String(public_base_url || '').trim().replace(/\/$/, ''); + envContent = setEnvVar(envContent, 'PUBLIC_BASE_URL', cleanPublicBaseUrl); + process.env.PUBLIC_BASE_URL = cleanPublicBaseUrl; + } + + writeFileSync(envPath, envContent, 'utf-8'); + if (req.user) auditLog(req.user.id, req.user.email, 'settings.updated', null, null, null, getClientIp(req)); + const result = updateSettings(dbSettings); + result.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + result.has_openai_key = !!process.env.OPENAI_API_KEY; + result.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + result.has_etsy_key = !!process.env.ETSY_API_KEY; + result.etsy_connected = etsyApi.isConnected(); + result.has_ebay_credentials = ebayApi.hasCredentials(); + result.ebay_connected = ebayApi.isConnected(); + result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + result.public_base_url = process.env.PUBLIC_BASE_URL || ''; + res.json(result); +}); + +// --- HTML Templates --- +app.get('/api/html-templates', (_req, res) => res.json(getHtmlTemplates())); + +app.post('/api/html-wrap', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const html = wrapInTemplate(listing, platform || listing.platform); + res.json({ html }); +}); + +// Live rendering for the generator and listing editor. No data is persisted here. +app.post('/api/html-render', (req, res) => { + try { + const listing = normalizeListingPayload(req.body?.listing || req.body || {}); + const platform = req.body?.platform || listing.platform || 'vinted'; + res.json({ html: wrapInTemplate(listing, platform) }); + } catch (err) { + res.status(400).json({ error: err.message || 'HTML konnte nicht gerendert werden' }); + } +}); + +// --- Publishing --- +const PUBLISH_URLS = { + vinted: 'https://www.vinted.de/items/new', + 'ebay-ka': 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', + 'ebay-de': 'https://www.ebay.de/sell/create', +}; + +app.get('/api/publish/info/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const logs = getPublishLogs(listing.id); + const platform = listing.platform; + const publishUrl = PUBLISH_URLS[platform] || null; + const canApiPublish = (platform === 'etsy' && etsyApi.isConnected()) || (platform === 'ebay-de' && ebayApi.isConnected()); + res.json({ listing, logs, publishUrl, canApiPublish }); +}); + +app.post('/api/publish/smart-copy', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const targetPlatform = platform || listing.platform; + const publishUrl = PUBLISH_URLS[targetPlatform] || null; + createPublishLog(listing_id, targetPlatform, 'copied'); + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : listing.tags; + res.json({ + publishUrl, + fields: { + title: listing.title, + description: listing.description, + tags, + price: listing.price || '', + brand: listing.brand || '', + category: listing.category || '', + size: listing.size || '', + color: listing.color || '', + condition: listing.condition || '', + }, + photos: listing.photos, + }); +}); + +app.post('/api/publish/mark', (req, res) => { + const { listing_id, platform, status } = req.body; + createPublishLog(listing_id, platform || 'unknown', status || 'published'); + res.json({ ok: true }); +}); + +app.get('/api/publish/status', (req, res) => { + const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); + res.json(getPublishStatus(ids)); +}); + +app.post('/api/publish/batch-photos', async (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Listings' }); + const allPhotos = []; + for (const id of listing_ids) { + const listing = getListing(id); + if (listing?.photos) { + for (const p of listing.photos) allPhotos.push(p); + } + } + res.json({ photos: allPhotos }); +}); + +app.post('/api/photos/archive', (req, res) => { + const requested = Array.isArray(req.body?.photos) ? req.body.photos : []; + const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : []; + const photos = [...requested]; + for (const id of listingIds) { + const listing = getListing(Number(id)); + if (listing?.photos?.length) photos.push(...listing.photos); + } + + const unique = [...new Set(photos.map(photo => String(photo || '').replace(/\\/g, '/')).filter(Boolean))]; + if (unique.length < 2) return res.status(400).json({ error: 'Für ein ZIP werden mindestens zwei Fotos benötigt' }); + + const uploadRoot = resolve(__dirname, 'uploads'); + const files = []; + for (const [index, relative] of unique.entries()) { + const absolute = resolve(uploadRoot, relative); + if (absolute !== uploadRoot && !absolute.startsWith(uploadRoot + sep)) continue; + if (!existsSync(absolute) || !statSync(absolute).isFile()) continue; + const originalName = basename(relative) || `foto-${index + 1}.jpg`; + const prefix = String(index + 1).padStart(2, '0'); + files.push({ name: `${prefix}-${originalName}`, data: readFileSync(absolute) }); + } + + if (files.length < 2) return res.status(404).json({ error: 'Nicht genügend Fotodateien gefunden' }); + const archive = createZip(files); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-fotos-${date}.zip`); + res.setHeader('Content-Length', archive.length); + res.send(archive); +}); + +// --- Etsy OAuth --- +app.get('/auth/etsy', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + const url = etsyApi.getAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte ETSY_API_KEY in Einstellungen setzen.`); + } +}); + +app.get('/auth/etsy/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + await etsyApi.handleCallback(code, state, redirectUri); + res.send('

                                              Etsy verbunden!

                                              Du kannst dieses Fenster schließen.

                                              '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/etsy/status', (_req, res) => { + res.json({ connected: etsyApi.isConnected(), hasApiKey: !!process.env.ETSY_API_KEY }); +}); + +app.post('/api/publish/etsy', async (req, res) => { + try { + if (!etsyApi.isConnected()) return res.status(400).json({ error: 'Nicht mit Etsy verbunden' }); + const { listing_id } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Listing nicht gefunden' }); + + const shopId = await etsyApi.getShopId(); + const etsyListing = await etsyApi.createEtsyListing(listing, shopId); + + if (listing.photos?.length) { + for (const photo of listing.photos) { + const photoPath = join(__dirname, 'uploads', photo); + if (existsSync(photoPath)) { + const imageBuffer = readFileSync(photoPath); + await etsyApi.uploadEtsyImage(shopId, etsyListing.listing_id, imageBuffer, photo); + } + } + } + + createPublishLog(listing_id, 'etsy', 'published', String(etsyListing.listing_id), `https://www.etsy.com/listing/${etsyListing.listing_id}`); + res.json({ ok: true, etsyListingId: etsyListing.listing_id, url: `https://www.etsy.com/listing/${etsyListing.listing_id}` }); + } catch (err) { + console.error('Etsy publish error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Extension Queue --- +app.get('/api/extension/queue', (req, res) => { + const platform = req.query.platform || 'vinted'; + const all = getListings(null, platform, 'active'); + const ids = all.map(l => l.id); + const pubStatus = getPublishStatus(ids); + const queue = all.filter(l => { + const ps = pubStatus[l.id]; + return !ps || !ps[platform] || ps[platform] === 'pending' || ps[platform] === 'copied'; + }); + const published = all.filter(l => { + const ps = pubStatus[l.id]; + return ps && ps[platform] === 'published'; + }); + res.json({ queue, published, total: all.length }); +}); + +app.get('/api/extension/listing/:id/photos', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const baseUrl = resolvePublicBaseUrl(req); + const photoUrls = (listing.photos || []).map(filename => `${baseUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`); + res.json({ photos: photoUrls, listing }); +}); + +// --- eBay OAuth --- +app.get('/auth/ebay', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + const url = ebayApi.getOAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte eBay Credentials in Einstellungen setzen.`); + } +}); + +app.get('/auth/ebay/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + await ebayApi.handleCallback(code, state, redirectUri); + res.send('

                                              eBay verbunden!

                                              Du kannst dieses Fenster schließen.

                                              '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/ebay/status', (_req, res) => { + res.json({ + connected: ebayApi.isConnected(), + hasCredentials: ebayApi.hasCredentials(), + sandbox: process.env.EBAY_SANDBOX === 'true', + }); +}); + +app.get('/api/ebay/policies', async (_req, res) => { + try { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + const policies = await ebayApi.getPolicies(); + res.json(policies); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// --- eBay Queue --- +app.get('/api/ebay/queue', (_req, res) => { + res.json(getEbayQueue()); +}); + +app.post('/api/ebay/queue', (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Listings' }); + const added = addToEbayQueue(listing_ids); + res.json({ added: added.length, queue: getEbayQueue() }); +}); + +app.delete('/api/ebay/queue/:id', (req, res) => { + removeFromEbayQueue(req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/ebay/queue', (req, res) => { + clearEbayQueue(req.query.status || null); + res.json({ ok: true }); +}); + +app.post('/api/ebay/publish/:id', async (req, res) => { + try { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Listing nicht gefunden' }); + + const sku = listing.sku || `AL-${listing.id}`; + const result = await ebayApi.createAndPublish(listing, sku); + + createPublishLog(listing.id, 'ebay-de', 'published', result.listingId, result.url); + res.json({ ok: true, ...result }); + } catch (err) { + console.error('eBay publish error:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/ebay/queue/process', async (_req, res) => { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + + const pending = getEbayQueue('pending'); + const results = []; + + for (const item of pending) { + updateEbayQueue(item.id, { status: 'uploading' }); + try { + const listing = getListing(item.listing_id); + if (!listing) { updateEbayQueue(item.id, { status: 'failed', error: 'Listing nicht gefunden' }); continue; } + + const sku = listing.sku || `AL-${listing.id}`; + const result = await ebayApi.createAndPublish(listing, sku); + + updateEbayQueue(item.id, { status: 'published', ebay_item_id: result.listingId, ebay_url: result.url }); + createPublishLog(listing.id, 'ebay-de', 'published', result.listingId, result.url); + results.push({ id: item.id, listing_id: item.listing_id, status: 'published', url: result.url }); + } catch (err) { + console.error(`eBay queue item ${item.id} failed:`, err); + updateEbayQueue(item.id, { status: 'failed', error: err.message, retries: (item.retries || 0) + 1 }); + results.push({ id: item.id, listing_id: item.listing_id, status: 'failed', error: err.message }); + } + } + + res.json({ processed: results.length, results, queue: getEbayQueue() }); +}); + +app.post('/api/ebay/queue/retry', (_req, res) => { + const failed = getEbayQueue('failed'); + for (const item of failed) { + updateEbayQueue(item.id, { status: 'pending', error: null }); + } + res.json({ retried: failed.length, queue: getEbayQueue() }); +}); + +// --- Categories --- +app.get('/api/categories/:platform', (req, res) => { + const tree = getCategories(req.params.platform); + const flat = getCategoriesFlat(req.params.platform); + res.json({ tree, flat }); +}); + +app.get('/api/categories/:platform/match', (req, res) => { + const q = req.query.q || ''; + res.json(matchCategories(req.params.platform, q)); +}); + +// --- Studio Flow Dashboard --- +app.get('/api/dashboard/workflow', (req, res) => { + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 12) : 3; + res.json(getDashboardWorkflow(limit)); +}); + +app.get('/api/dashboard/analytics', (req, res) => { + const days = Number.parseInt(req.query.days, 10) || 30; + res.json(getDashboardAnalytics(days)); +}); + +// --- Dashboard Stats --- +app.get('/api/dashboard/stats', (_req, res) => { + const listings = getListings(); + const total = listings.length; + const active = listings.filter(l => l.status === 'active').length; + const sold = listings.filter(l => l.status === 'sold').length; + const reserved = listings.filter(l => l.status === 'reserved').length; + + const totalValue = listings.reduce((s, l) => s + (l.price || 0), 0); + const soldValue = listings.filter(l => l.status === 'sold').reduce((s, l) => s + (l.price || 0), 0); + + const platforms = {}; + for (const l of listings) { + platforms[l.platform] = (platforms[l.platform] || 0) + 1; + } + + const now = new Date(); + const timeline = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().slice(0, 10); + const count = listings.filter(l => l.created_at && l.created_at.startsWith(dateStr)).length; + timeline.push({ date: dateStr, label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }), count }); + } + + const recent = listings.slice(0, 5).map(l => ({ + id: l.id, title: l.title, platform: l.platform, + price: l.price, sku: l.sku, status: l.status, + photo: l.photos?.[0] || null, + created_at: l.created_at, + })); + + res.json({ total, active, sold, reserved, totalValue, soldValue, platforms, timeline, recent }); +}); + +// --- Backup / Restore --- +app.get('/api/backup', requireRole('admin'), (_req, res) => { + const dbPath = join(__dirname, 'db', 'vendoo.db'); + if (!existsSync(dbPath)) return res.status(404).json({ error: 'DB nicht gefunden' }); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-backup-${new Date().toISOString().slice(0, 10)}.db`); + const buffer = readFileSync(dbPath); + res.send(buffer); +}); + +app.post('/api/restore', requireRole('admin'), upload.single('backup'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Datei' }); + const dbPath = join(__dirname, 'db', 'vendoo.db'); + const backupPath = dbPath + '.bak-' + Date.now(); + if (existsSync(dbPath)) { + writeFileSync(backupPath, readFileSync(dbPath)); + } + writeFileSync(dbPath, readFileSync(req.file.path)); + res.json({ ok: true, message: 'Backup eingespielt. Server-Neustart empfohlen.' }); +}); + +// --- Scheduling --- +app.get('/api/schedule', (_req, res) => { + res.json(getScheduledPublishes()); +}); + +app.post('/api/schedule', (req, res) => { + const { listing_id, platform, scheduled_at } = req.body; + if (!listing_id || !scheduled_at) return res.status(400).json({ error: 'listing_id und scheduled_at erforderlich' }); + const id = schedulePublish(listing_id, platform || 'ebay-de', scheduled_at); + res.json({ ok: true, id, scheduled_at }); +}); + +app.delete('/api/schedule/:id', (req, res) => { + removeScheduledPublish(req.params.id); + res.json({ ok: true }); +}); + +// --- Price Suggestion --- +app.get('/api/price-suggest', async (req, res) => { + const { title, category, condition } = req.query; + if (!title) return res.status(400).json({ error: 'Titel erforderlich' }); + + const listings = getListings(); + const words = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); + + const similar = listings + .filter(l => l.price && l.price > 0) + .map(l => { + const lTitle = (l.title || '').toLowerCase(); + const matchCount = words.filter(w => lTitle.includes(w)).length; + const catMatch = category && l.category && l.category.toLowerCase().includes(category.toLowerCase()) ? 2 : 0; + return { ...l, score: matchCount + catMatch }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + if (!similar.length) return res.json({ suggestion: null, similar: [] }); + + const prices = similar.map(s => s.price); + const avg = prices.reduce((a, b) => a + b, 0) / prices.length; + const min = Math.min(...prices); + const max = Math.max(...prices); + + res.json({ + suggestion: Math.round(avg * 2) / 2, + min, max, avg: Math.round(avg * 100) / 100, + count: similar.length, + similar: similar.slice(0, 5).map(s => ({ title: s.title, price: s.price, platform: s.platform })), + }); +}); + +// --- Providers & Platforms --- +app.get('/api/providers', (_req, res) => res.json(getProviders())); +app.get('/api/platforms', (_req, res) => { + res.json(Object.values(platforms).map(p => ({ + id: p.id, name: p.name, fields: p.fields, + maxTitleLength: p.maxTitleLength, maxTags: p.maxTags, + }))); +}); + +// --- Scheduler Timer --- +async function processScheduledPublishes() { + const due = getDueScheduled(); + for (const item of due) { + updateScheduledPublish(item.id, { status: 'publishing' }); + try { + const listing = getListing(item.listing_id); + if (!listing) { updateScheduledPublish(item.id, { status: 'failed', error: 'Listing nicht gefunden' }); continue; } + + if (item.platform === 'etsy' && etsyApi.isConnected()) { + const shopId = await etsyApi.getShopId(); + const etsyListing = await etsyApi.createEtsyListing(listing, shopId); + if (listing.photos?.length) { + for (const photo of listing.photos) { + const photoPath = join(__dirname, 'uploads', photo); + if (existsSync(photoPath)) { + const imageBuffer = readFileSync(photoPath); + await etsyApi.uploadEtsyImage(shopId, etsyListing.listing_id, imageBuffer, photo); + } + } + } + createPublishLog(item.listing_id, 'etsy', 'published', String(etsyListing.listing_id), `https://www.etsy.com/listing/${etsyListing.listing_id}`); + updateScheduledPublish(item.id, { status: 'published' }); + } else if (item.platform === 'ebay-de' && ebayApi.isConnected()) { + const sku = listing.sku || `AL-${listing.id}`; + const result = await ebayApi.createAndPublish(listing, sku); + createPublishLog(item.listing_id, 'ebay-de', 'published', result.listingId, result.url); + updateScheduledPublish(item.id, { status: 'published' }); + } else { + updateScheduledPublish(item.id, { status: 'failed', error: `Plattform ${item.platform} nicht verbunden` }); + } + console.log(`Scheduled publish ${item.id} (${listing.title}) → ${item.platform}: OK`); + } catch (err) { + console.error(`Scheduled publish ${item.id} failed:`, err.message); + updateScheduledPublish(item.id, { status: 'failed', error: err.message }); + } + } + if (due.length) console.log(`Scheduler: ${due.length} fällige Veröffentlichungen verarbeitet`); +} + +setInterval(processScheduledPublishes, 60_000); + +// --- Trash cleanup --- +function runTrashCleanup() { + try { + const result = cleanupTrash(30); + if (result.changes > 0) console.log(`Trash cleanup: ${result.changes} alte Einträge endgültig gelöscht`); + } catch (err) { console.error('Trash cleanup error:', err.message); } +} +setInterval(runTrashCleanup, 60 * 60 * 1000); // every hour + +// --- Start --- +await loadPlatforms(); +app.listen(PORT, '0.0.0.0', () => { + console.log(`Vendoo läuft lokal auf http://localhost:${PORT}`); + for (const entry of getLanIpv4Addresses()) console.log(`Vendoo im LAN: http://${entry.address}:${PORT} (${entry.name})`); + processScheduledPublishes(); + runTrashCleanup(); +}); diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.bat b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.bat new file mode 100644 index 0000000..ac2c827 --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.bat @@ -0,0 +1,5 @@ +@echo off +title Vendoo Setup +cd /d "%~dp0" +powershell -ExecutionPolicy Bypass -File "%~dp0setup.ps1" %* +pause diff --git a/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.ps1 b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.ps1 new file mode 100644 index 0000000..3a0fb2a --- /dev/null +++ b/Archiv/vorFlux/Vendoo_UI_2026_Slice_18_OpenRouter_Diagnostics_Mobile_Mediathek/vendoo_slice18/setup.ps1 @@ -0,0 +1,481 @@ +$ErrorActionPreference = 'Stop' +$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Version = '1.0.0' +$Port = 8124 + +function Write-Header { + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════╗' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ║ V E N D O O S E T U P ║' -ForegroundColor Green + Write-Host ' ║ Listing-Generator v'$Version' ║' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ╚══════════════════════════════════════╝' -ForegroundColor DarkGreen + Write-Host '' +} + +function Write-Step($step, $text) { + Write-Host " [$step] " -ForegroundColor Cyan -NoNewline + Write-Host $text +} + +function Write-OK($text) { + Write-Host " [OK] " -ForegroundColor Green -NoNewline + Write-Host $text +} + +function Write-Warn($text) { + Write-Host " [!] " -ForegroundColor Yellow -NoNewline + Write-Host $text +} + +function Write-Err($text) { + Write-Host " [X] " -ForegroundColor Red -NoNewline + Write-Host $text +} + +function Test-NodeInstalled { + try { + $v = & node --version 2>$null + if ($v -match '^v(\d+)') { + $major = [int]$Matches[1] + return @{ Installed = $true; Version = $v; Major = $major } + } + } catch {} + return @{ Installed = $false; Version = $null; Major = 0 } +} + +function Test-NpmInstalled { + try { + $v = & npm --version 2>$null + if ($v) { return $true } + } catch {} + return $false +} + + +function Stop-VendooNodeProcesses { + $stopped = 0 + try { + $escapedDir = [regex]::Escape($VendooDir) + $processes = Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and $_.CommandLine -match $escapedDir } + foreach ($process in $processes) { + try { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + $stopped++ + } catch {} + } + } catch {} + if ($stopped -gt 0) { + Write-Warn "$stopped laufende Vendoo-Node-Prozess(e) beendet" + Start-Sleep -Milliseconds 750 + } +} + +function Remove-NodeModulesSafe { + $nm = Join-Path $VendooDir 'node_modules' + if (-not (Test-Path $nm)) { return $true } + + Stop-VendooNodeProcesses + Write-Step '...' 'Unvollstaendige node_modules werden bereinigt...' + + try { + Remove-Item $nm -Recurse -Force -Confirm:$false -ErrorAction Stop + } catch { + try { + & cmd.exe /d /c "rmdir /s /q `"$nm`"" | Out-Null + } catch {} + } + + if (Test-Path $nm) { + Write-Err 'node_modules konnte nicht vollstaendig entfernt werden.' + Write-Host ' Ursache ist meist ein noch laufender Node-Prozess, Virenscanner oder Explorer-Zugriff.' -ForegroundColor Yellow + Write-Host ' Bitte alle Vendoo-/Node-Fenster schliessen, notfalls Windows neu starten und Reparieren erneut ausfuehren.' -ForegroundColor Yellow + return $false + } + return $true +} + +function Invoke-VendooNpmInstall([switch]$ForceClean) { + $logDir = Join-Path $VendooDir 'logs' + if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $logFile = Join-Path $logDir 'last-npm-install.log' + $lockFile = Join-Path $VendooDir 'package-lock.json' + $command = if (Test-Path $lockFile) { 'ci' } else { 'install' } + + if ($ForceClean) { + if (-not (Remove-NodeModulesSafe)) { return $false } + } + + $attempt = 1 + while ($attempt -le 2) { + Push-Location $VendooDir + try { + $output = & npm.cmd $command '--no-audit' '--no-fund' 2>&1 + $exitCode = $LASTEXITCODE + } catch { + $output = @($_.Exception.Message) + $exitCode = 1 + } finally { + Pop-Location + } + + $output | Set-Content $logFile -Encoding utf8 + if ($exitCode -eq 0) { + Write-OK "Dependencies mit npm $command installiert" + return $true + } + + $text = ($output | Out-String) + if ($attempt -eq 1 -and $text -match 'ENOTEMPTY|EPERM|EBUSY') { + Write-Warn 'npm meldet einen gesperrten oder unvollstaendigen node_modules-Ordner.' + if (-not (Remove-NodeModulesSafe)) { return $false } + try { & npm.cmd cache verify 2>&1 | Out-Null } catch {} + Write-Step '...' 'npm-Installation wird einmal sauber wiederholt...' + $attempt++ + continue + } + + Write-Err "npm $command fehlgeschlagen (Exit-Code $exitCode)" + Write-Host " Vollstaendiges Protokoll: $logFile" -ForegroundColor Yellow + $lastLines = @($output | Select-Object -Last 12) + foreach ($line in $lastLines) { Write-Host " $line" -ForegroundColor DarkYellow } + return $false + } + return $false +} + +function Install-Vendoo { + Write-Header + Write-Host ' Installation starten...' -ForegroundColor White + Write-Host '' + + # 1. Node.js pruefen + Write-Step '1/5' 'Node.js pruefen...' + $node = Test-NodeInstalled + if (-not $node.Installed) { + Write-Err 'Node.js ist nicht installiert!' + Write-Host '' + Write-Host ' Node.js wird benoetigt (mindestens v18).' -ForegroundColor Yellow + Write-Host ' Download: https://nodejs.org/' -ForegroundColor Cyan + Write-Host '' + $dl = Read-Host ' Node.js jetzt automatisch installieren? (j/n)' + if ($dl -eq 'j') { + Write-Step '...' 'Lade Node.js Installer herunter...' + $installerUrl = 'https://nodejs.org/dist/v22.16.0/node-v22.16.0-x64.msi' + $installerPath = Join-Path $env:TEMP 'node-installer.msi' + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing + Write-Step '...' 'Installiere Node.js...' + Start-Process msiexec.exe -ArgumentList "/i `"$installerPath`" /qn" -Wait -NoNewWindow + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + Start-Sleep -Seconds 2 + $node = Test-NodeInstalled + if ($node.Installed) { + Write-OK "Node.js $($node.Version) installiert" + } else { + Write-Err 'Installation fehlgeschlagen. Bitte manuell installieren.' + return + } + } catch { + Write-Err "Download fehlgeschlagen: $_" + Write-Host ' Bitte installiere Node.js manuell: https://nodejs.org/' -ForegroundColor Yellow + return + } + } else { + Write-Host ' Installation abgebrochen.' -ForegroundColor Yellow + return + } + } elseif ($node.Major -lt 18) { + Write-Warn "Node.js $($node.Version) ist zu alt. Mindestens v18 erforderlich." + Write-Host ' Update: https://nodejs.org/' -ForegroundColor Cyan + return + } else { + Write-OK "Node.js $($node.Version) gefunden" + } + + # 2. npm pruefen + Write-Step '2/5' 'npm pruefen...' + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV gefunden" + } else { + Write-Err 'npm nicht gefunden. Wird mit Node.js mitgeliefert.' + return + } + + # 3. Dependencies reproduzierbar installieren + Write-Step '3/5' 'Dependencies installieren (npm ci)...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # 4. .env erstellen + Write-Step '4/5' '.env Konfiguration...' + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if (-not (Test-Path $envFile)) { + if (Test-Path $envExample) { + Copy-Item $envExample $envFile + Write-OK '.env aus .env.example erstellt' + } else { + @" +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +OPENROUTER_API_KEY= +OLLAMA_URL=http://localhost:11434 +PORT=$Port +"@ | Set-Content $envFile -Encoding utf8 + Write-OK '.env erstellt (API-Keys muessen noch eingetragen werden)' + } + Write-Warn 'Trage deine API-Keys in die .env Datei ein oder in den Einstellungen der App.' + } else { + Write-OK '.env existiert bereits' + } + + # 5. Ordner pruefen + Write-Step '5/5' 'Verzeichnisse pruefen...' + $dirs = @('uploads', 'db') + foreach ($d in $dirs) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { + New-Item -ItemType Directory -Path $p -Force | Out-Null + } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' ========================================' -ForegroundColor Green + Write-Host ' Installation abgeschlossen!' -ForegroundColor Green + Write-Host ' ========================================' -ForegroundColor Green + Write-Host '' + Write-Host " Starte Vendoo mit: " -NoNewline + Write-Host 'npm start' -ForegroundColor Cyan + Write-Host " Oder im Dev-Modus: " -NoNewline + Write-Host 'npm run dev' -ForegroundColor Cyan + Write-Host " Dann oeffne: " -NoNewline + Write-Host "http://localhost:$Port" -ForegroundColor Cyan + Write-Host '' + + $start = Read-Host ' Vendoo jetzt starten? (j/n)' + if ($start -eq 'j') { + Start-Vendoo + } +} + +function Start-Vendoo { + Write-Host '' + Write-Step '...' 'Starte Vendoo-Server...' + Push-Location $VendooDir + Start-Process "http://localhost:$Port" + & node server.mjs + Pop-Location +} + +function Update-Vendoo { + Write-Header + Write-Host ' Update durchfuehren...' -ForegroundColor White + Write-Host '' + + # Dependencies reproduzierbar aktualisieren + Write-Step '1/2' 'Dependencies aktualisieren...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # Neue .env Eintraege pruefen + Write-Step '2/2' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if ((Test-Path $envFile) -and (Test-Path $envExample)) { + $current = Get-Content $envFile -Raw + $example = Get-Content $envExample + $added = 0 + foreach ($line in $example) { + if ($line -match '^([A-Z_]+)=' -and $current -notmatch "(?m)^$($Matches[1])=") { + Add-Content $envFile "`n$line" + $added++ + } + } + if ($added -gt 0) { + Write-OK "$added neue Konfigurationseintraege hinzugefuegt" + } else { + Write-OK '.env ist aktuell' + } + } + + Write-Host '' + Write-Host ' Update abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Repair-Vendoo { + Write-Header + Write-Host ' Reparatur durchfuehren...' -ForegroundColor White + Write-Host '' + + # 1. node_modules robust loeschen und reproduzierbar neu installieren + Write-Step '1/3' 'node_modules neu installieren...' + if (-not (Invoke-VendooNpmInstall -ForceClean)) { return } + + # 2. .env pruefen + Write-Step '2/3' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path $envFile)) { + $envExample = Join-Path $VendooDir '.env.example' + if (Test-Path $envExample) { Copy-Item $envExample $envFile } + Write-Warn '.env fehlte und wurde neu erstellt' + } else { + Write-OK '.env vorhanden' + } + + # 3. Ordner pruefen + Write-Step '3/3' 'Verzeichnisse pruefen...' + foreach ($d in @('uploads', 'db')) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' Reparatur abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Uninstall-Vendoo { + Write-Header + Write-Host ' Vendoo deinstallieren' -ForegroundColor Red + Write-Host '' + Write-Warn 'Dies entfernt node_modules und optionale Daten.' + Write-Host '' + + $confirm = Read-Host ' Wirklich deinstallieren? (ja/nein)' + if ($confirm -ne 'ja') { + Write-Host ' Abgebrochen.' -ForegroundColor Yellow + return + } + + $keepData = Read-Host ' Datenbank und Uploads behalten? (j/n)' + + Write-Step '1/2' 'node_modules entfernen...' + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { + Remove-Item $nm -Recurse -Force -Confirm:$false + Write-OK 'node_modules entfernt' + } else { + Write-OK 'node_modules nicht vorhanden' + } + + if ($keepData -ne 'j') { + Write-Step '2/2' 'Daten entfernen...' + $dbDir = Join-Path $VendooDir 'db' + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $dbDir) { + Remove-Item (Join-Path $dbDir '*.db'), (Join-Path $dbDir '*.db-shm'), (Join-Path $dbDir '*.db-wal') -Force -ErrorAction SilentlyContinue + Write-OK 'Datenbank entfernt' + } + if (Test-Path $uploadsDir) { + Get-ChildItem $uploadsDir -File | Remove-Item -Force -Confirm:$false + Write-OK 'Uploads entfernt' + } + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { + Remove-Item $envFile -Force -Confirm:$false + Write-OK '.env entfernt' + } + } else { + Write-OK 'Datenbank und Uploads beibehalten' + } + + Write-Host '' + Write-Host ' Vendoo wurde deinstalliert.' -ForegroundColor Green + Write-Host ' Die Projektdateien bleiben erhalten.' -ForegroundColor DarkGray + Write-Host ' Zum erneuten Installieren: setup.bat ausfuehren.' -ForegroundColor DarkGray + Write-Host '' +} + +function Show-Status { + Write-Header + Write-Host ' Systemstatus' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + + $node = Test-NodeInstalled + if ($node.Installed) { Write-OK "Node.js $($node.Version)" } + else { Write-Err 'Node.js Nicht installiert' } + + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV" + } else { Write-Err 'npm Nicht gefunden' } + + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { Write-OK 'Dependencies Installiert' } + else { Write-Warn 'Dependencies Nicht installiert' } + + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { Write-OK '.env Vorhanden' } + else { Write-Warn '.env Fehlt' } + + $dbFile = Join-Path $VendooDir 'db' 'vendoo.db' + if (Test-Path $dbFile) { + $size = (Get-Item $dbFile).Length + $sizeStr = if ($size -gt 1MB) { "{0:N1} MB" -f ($size / 1MB) } else { "{0:N0} KB" -f ($size / 1KB) } + Write-OK "Datenbank $sizeStr" + } else { Write-Warn 'Datenbank Noch nicht erstellt (wird beim ersten Start angelegt)' } + + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $uploadsDir) { + $fileCount = (Get-ChildItem $uploadsDir -File -ErrorAction SilentlyContinue).Count + Write-OK "Uploads $fileCount Dateien" + } + + Write-Host '' + Write-Host " Port: $Port" -ForegroundColor DarkGray + Write-Host " Verzeichnis: $VendooDir" -ForegroundColor DarkGray + Write-Host '' +} + +# ── Main Menu ── +function Show-Menu { + while ($true) { + Write-Header + Write-Host ' Waehle eine Option:' -ForegroundColor White + Write-Host '' + Write-Host ' 1 Installieren' -ForegroundColor Cyan + Write-Host ' 2 Starten' -ForegroundColor Cyan + Write-Host ' 3 Update' -ForegroundColor Cyan + Write-Host ' 4 Reparieren' -ForegroundColor Cyan + Write-Host ' 5 Deinstallieren' -ForegroundColor Cyan + Write-Host ' 6 Status anzeigen' -ForegroundColor Cyan + Write-Host ' 0 Beenden' -ForegroundColor DarkGray + Write-Host '' + $choice = Read-Host ' Auswahl' + + switch ($choice) { + '1' { Install-Vendoo } + '2' { Start-Vendoo } + '3' { Update-Vendoo } + '4' { Repair-Vendoo } + '5' { Uninstall-Vendoo } + '6' { Show-Status } + '0' { Write-Host ' Auf Wiedersehen!' -ForegroundColor Green; return } + default { Write-Warn 'Ungueltige Auswahl' } + } + Write-Host '' + Read-Host ' Druecke Enter um fortzufahren' + } +} + +# Handle CLI arguments for direct commands +if ($args.Count -gt 0) { + switch ($args[0]) { + 'install' { Install-Vendoo } + 'start' { Start-Vendoo } + 'update' { Update-Vendoo } + 'repair' { Repair-Vendoo } + 'uninstall' { Uninstall-Vendoo } + 'status' { Show-Status } + default { Show-Menu } + } +} else { + Show-Menu +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 65568a9..b643c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,710 @@ # Changelog -Alle wesentlichen Änderungen an Vendoo werden in dieser Datei dokumentiert. +## 1.36.0 – Secure Theme Manager, Accessibility & Component Token Foundation + +### Neu + +- `vendoo.themes` als erstes natives Fachmodul auf dem Plattformkernel. +- Persönliche Theme-Präferenzen pro Benutzer mit Theme, UI-Dichte und reduzierter Bewegung. +- Sichere Theme-Profile für Administratoren mit Basis-Theme, Token-Overrides, Import, Export und Systemstandard. +- Live-Vorschau im Browser; Speichern erst nach expliziter Bestätigung. +- Serverseitige Validierung aller Theme-Werte und WCAG-orientierte Kontrastprüfung zentraler Text-/Oberflächenpaare. +- Light, Dark, System und High Contrast als integrierte Modi. +- 42 typisierte Design Tokens, darunter erste Komponenten-, Fokus- und Touch-Target-Tokens. +- Tokenisierte Buttons, Eingabefelder, Karten, Dialoge und Fokuszustände. +- Deny-by-default API-Verträge für Theme-Routen sowie getrennte Benutzer- und Administrator-Policies. +- Audit-Protokoll für persönliche Präferenzen, Theme-Profile und Systemstandard. +- Eigenes `verify:themes`-Gate in lokaler Prüfung, GitHub CI und Release-Workflow. +- GitHub Draft Pull Request #9 für `release/1.36.0-theme-manager`. + +### Sicherheit und Kompatibilität + +- Keine freie CSS-, JavaScript-, URL- oder Dateipfad-Eingabe. +- Unbekannte Tokens und Theme-IDs werden abgewiesen. +- Normale Benutzer dürfen ausschließlich ihre eigene Darstellung ändern. +- Systemstandard und benutzerdefinierte Profile erfordern administrative Berechtigung. +- Bestehende Daten, Benutzer, Artikel, Uploads, Backups und FLUX-Daten bleiben erhalten. +- Kein Reset, keine Framework-Migration und keine Datenbankablösung. + +## 1.35.0 – Modular Platform Kernel, Design-System Contracts & Security Architecture Foundation + +### Neu + +- Modularer Plattformkernel mit Lifecycle, Event Bus, Feature Flags, Modul- und Policy-Registry. +- Zwölf bestehende Fachbereiche als validierte `legacy-bridge`-Module mit expliziten Abhängigkeiten, Capabilities und Ownership. +- Deny-by-default Route Registry: neue Kernel-Routen benötigen Besitzer und explizite Policy. +- Administrativ geschützte Plattform-, Modul- und Theme-Vertragsendpunkte. +- Kernel-Healthcheck in `/readyz` und geordneter Kernel-Shutdown. +- Versionierte Design-Token-Quelle mit 33 Tokens für Light, Dark und High Contrast. +- Deterministischer Token-Generator und browserseitige Theme Runtime. +- Sichere Theme-Definitionen: nur freigegebene Farb- und Dimensionswerte, kein freies CSS/JS. +- Modul-JSON-Schema, Architekturgrenzen, Migrationsroadmap und Security-Architekturdokumentation. +- Neues `npm run verify:architecture` mit Zyklus-, Policy-, Event-, Theme- und Token-Regressionstests. +- GitHub Draft Pull Request #7 für `release/1.35.0-platform-kernel`. + +### Kompatibilität + +- Kein Reset, keine Framework-Migration, keine Datenbankablösung. +- Windows-Lokalbetrieb, Docker, NAS, VPS, Zero-Edit-Installer und Git-Sicherheitsgate bleiben erhalten. +- Bestehende CSS-Variablen bleiben über eine Token-Kompatibilitätsbrücke funktionsfähig. +- Bestehende Routen werden in 1.35.0 noch nicht massenhaft verschoben; die Migration erfolgt fachmodulweise. + +## 1.34.3 – Git-Staging Update-Archiv-Ausschluss Hotfix + +### Behoben +- Der lokale Ordner `updates/` wird als Update-, Rollback- und Slice-Artefaktspeicher behandelt und nicht nach GitHub oder in Release-Staging-Verzeichnisse übernommen. +- Alte Sicherungskopien wie `updates/backups/.../setup.bat`, `setup.ps1` oder `scripts/install-local-flux.ps1` lösen das Git-Sicherheitsgate nicht mehr aus, weil sie bereits vor dem Scan ausgeschlossen werden. +- Die aktiven Installationsdateien im Projektroot und unter `scripts/` bleiben Teil des Quellimports. +- Das Git-Sicherheitsgate blockiert `updates/` weiterhin, falls ein fremder Kopierweg den Ordner dennoch in das Staging einschleust. +- `.gitignore` schützt den lokalen Updateverlauf zusätzlich vor versehentlichen Commits. + +### Qualitätssicherung +- Regressionstest mit den drei konkret gemeldeten Pfaden aus `slice22_1_20260707-194953`. +- Release-Builder und GitHub-Deploy-Assistent verwenden weiterhin dieselbe Filterfunktion. +- Neuer Importbranch `release/1.34.3-initial-import` und Draft Pull Request #6. + +## 1.34.2 – Git-Sicherheitsdiagnose & gemeinsamer Staging-Builder + +### Behoben +- Die Meldung `Git-Sicherheitsgate fehlgeschlagen (4)` wird nicht mehr ohne Detailzeilen ausgegeben. Der Windows-Assistent fängt stdout und stderr getrennt ab und zeigt jeden konkreten Treffer an. +- Der vollständige Fehlerbericht wird unter `logs/github-deploy-safety-report.txt` gespeichert und unter Windows automatisch im Editor geöffnet. +- Windows, Linux und macOS verwenden denselben Node-basierten Staging-Builder statt unterschiedlicher Robocopy-/rsync-Regeln. +- Lokale `.env.*`-Dateien, Operations-Daten, Uploads, Backups, Logs, Datenbanken, Schlüsseldateien und FLUX-/ComfyUI-Laufzeitdaten werden vor dem Sicherheitsgate automatisch aus dem temporären Git-Staging ausgeschlossen. +- `.env.example` und `.env.docker.example` bleiben als sichere Vorlagen enthalten. +- Der Staging-Builder leert nur den Arbeitsbaum des temporären Klons und erhält dessen `.git`-Verzeichnis vollständig. + +### Qualitätssicherung +- Neuer plattformübergreifender Git-Staging-Regressionstest. +- Der Test belegt, dass lokale Secrets und Laufzeitdaten ausgeschlossen, öffentliche Vorlagen übernommen und `.git` nicht verändert werden. +- CI und Release-Workflow führen den Staging-Regressionstest zusätzlich zum Secret-Regressionstest aus. +- Neuer Importbranch `release/1.34.2-initial-import` und Draft Pull Request #5. + +## 1.34.1 – Git-Sicherheitsgate Windows Hotfix + +### Behoben +- Windows-Pfade werden im Git-Sicherheitsgate mit `fileURLToPath(import.meta.url)` in echte Dateisystempfade umgewandelt. +- Der Sicherheitscheck scannt den vollständigen Import-Arbeitsbaum und nicht nur bereits von Git verfolgte Dateien. +- Noch unversionierte `.env.local`, `.env.production`, Token-Dateien und sonstige Secrets werden vor `git add` erkannt. +- Node.js ist für den sicheren Import verpflichtend; ein stilles Überspringen des erweiterten Scans ist nicht möglich. + +## 1.34.0 – GitHub CI/CD & Zero-Edit Installation Foundation + +### Installation +- Grafischer Windows-Installationsassistent für lokalen Node.js-Betrieb und Docker Desktop. +- Geführte Linux-/NAS-/VPS-Konfiguration ohne manuelles Bearbeiten von `.env` oder Compose-Dateien. +- Automatische Erzeugung und sichere Ablage des Erst-Admin-Setup-Codes. +- VPS-Modus mit integriertem Caddy-Reverse-Proxy und automatischem HTTPS. +- Konfiguration kann später über den Assistenten geändert werden, ohne bestehende Daten zu löschen. +- Persistenter Runtime-Konfigurationsspeicher für Eingabemasken im gehärteten read-only Container. +- Simulierbares Shell-Installer-Gate für Docker-, NAS- und VPS-Modus; dabei wurde die Standardwert-Abfrage gegen Prompt-Vermischung gehärtet. + +### GitHub und Releases +- Git-Sicherheitsgate gegen Secrets, Datenbanken, Uploads, Backups, Logs, Runtime- und Modellordner. +- GitHub Actions für CI, Docker-Build, Release-Pakete, SHA-256 und GHCR-Images. +- Windows- und Shell-GitHub-Deploy-Assistent für kontrollierten Erstimport auf einen Release-Branch. +- Privater Branch `release/1.34.0-initial-import` und Draft Pull Request #3 real vorbereitet. +- Release-Pakete werden aus einem bereinigten Staging-Verzeichnis erzeugt und nicht als Binärdateien in die Git-Historie aufgenommen. + +### Sicherheit +- GitHub Actions besitzen jobbezogen minimale Berechtigungen. +- GHCR-Images erhalten eine Provenienz-Attestierung auf Basis des tatsächlich veröffentlichten Image-Digests. +- Container-Registry-Veröffentlichung erfolgt nur bei veröffentlichten GitHub Releases und passendem Versionstag. +- Produktions-Secrets bleiben in `.env` beziehungsweise GitHub Environments und niemals im Repository. + +## 1.33.0 – Multiplatform Deployment Wizard & Flux/Admin Fixes + +- Installationsziel-Assistent für Windows lokal, Docker Desktop, NAS/Portainer und VPS/Linux +- neues `setup.sh` für Linux-, NAS- und VPS-Zielsysteme +- Installationsmodus wird für Start, Update, Reparatur, Status und Deinstallation gespeichert +- Docker-Host-Gateway für einen getrennten FLUX/ComfyUI-Dienst +- FLUX Studio startet ohne automatische Bildauswahl +- Reset-Button leert den Arbeitsbereich, ohne Verlauf, Bilder oder Jobs zu löschen +- Benutzerbearbeitung auf stabile Event-Listener und Cachelogik umgestellt +- Passwortvalidierung vor dem Update und sanitisierte Benutzerantwort + +## 1.32.0 – Docker, NAS & Production Deployment Foundation + +- produktionsgeeignetes, mehrstufiges Dockerfile mit Lockfile-Installation +- Compose mit getrennten persistenten Volumes, non-root, read-only und Healthcheck +- zentrale, konfigurierbare DB-/Upload-/Backup-/Operations-/Log-Pfade bei voller Windows-Kompatibilität +- `/readyz` mit SQLite- und Schreibpfadprüfung +- geordneter SIGTERM-/SIGINT-Shutdown mit WAL-Checkpoint +- geschütztes Erst-Admin-Setup über `VENDOO_SETUP_TOKEN`; übrige API bleibt bis zum Setup gesperrt +- konfigurierbare Proxy- und Cookie-Regeln +- produktive Allowlist für Browser-Extension-Origins +- Uploads nur angemeldet abrufbar und strengere Dateitypprüfung +- NAS-, Portainer-, Backup-, Restore-, Update- und Rollback-Dokumentation +- FLUX/ComfyUI bleibt getrennt und ist im Hauptcontainer standardmäßig deaktiviert + +## 1.31.0 – Mehrbenutzer- und Server-Sicherheitsgrundlage + +- Fünf feste Rollen mit serverseitig erzwungenen Berechtigungen: Administrator, Manager, Editor, Publisher und Leser. +- Sichere Sitzungen mit absoluter und inaktiver Laufzeit, Widerruf, Benutzer-Sitzungsverwaltung und Login-Sperre. +- Bearbeitungssperren für Artikel mit automatischer Verlängerung und kontrollierter Freigabe. +- Filterbares und exportierbares Audit-Protokoll. +- Rate Limits, Host-/Origin-Freigaben, Request-IDs, CSP/Helmet und standardmäßige Bindung an localhost. +- Benutzer- und Sicherheitsoberfläche mit Rollenmatrix, aktiven Sperren und Sicherheitsstatus. +- Additive Migrationen; bestehende Artikel, Datenbank, Bilder und Einstellungen bleiben erhalten. + +## 1.30.0 – Operations Center: Backup, Restore, Updates & Extension-Diagnose + +- Neue Betriebszentrale unter Admin → System mit Backup-, Update- und Extension-Diagnose-Tabs. +- Konsistente SQLite-Backups über die Backup-API statt Kopieren der geöffneten Datenbank. +- Vollbackups mit Uploads, optionaler `.env`, Manifest, SHA-256-Dateiprüfsummen und SQLite-Integritätsprüfung. +- Tägliche rotierende automatische Backups mit konfigurierbarer Uhrzeit, Aufbewahrung und Upload-Option. +- Verifizierte Wiederherstellung mit automatischem Sicherheitsbackup und bewusstem Offline-Anwenden. +- Update Center prüft Version, Pflichtdateien und verbotene Laufzeitdaten, bevor ein Paket vorbereitet wird. +- Setup-Menüpunkt 11 wendet vorbereitete Update-/Restore-Operationen an; Menüpunkt 12 führt den Rollback aus. +- Migrationsstatus und letzte Systemoperation sind in der Oberfläche sichtbar. +- Vendoo Link 1.9.0 sendet Diagnose-Heartbeats; Chrome, Edge, Firefox und Safari werden getrennt ausgewertet. +- Keine Datenbank, Uploads, `.env`, API-Schlüssel oder Modelle werden durch ein Update-Paket gelöscht. + +## 1.29.0 – Slice 34: Publishing Hardening + +- plattformübergreifende persistente Publishing-Queue +- Doppelveröffentlichungsschutz und Idempotenzprüfung +- automatische Retry-Strategie und Neustart-Wiederaufnahme +- Fehlerzentrale, Ereignisprotokoll, externe IDs und Links +- eBay Sync, Update und Beenden über offizielle Inventory API +- Etsy Statusabgleich +- Extension-Aufträge für Vinted und Kleinanzeigen +- Scheduler an die gehärtete Queue angebunden +- Vendoo Link 1.8.0 + +## 1.28.0 – Slice 33: Artikel Quality Center + +- Neuer Hauptmenüpunkt **Qualitätscenter** mit Score, Noten A–E, Blockern, Warnungen und Empfehlungen. +- Serverseitige Prüfung von Titel, Beschreibung, Pflichtfeldern, Preis, Organisation, Suchbegriffen und plattformbezogenen Vendoo-Profilen. +- Vollständige Bildprüfung mit Dateiexistenz, Lesbarkeit, Abmessungen, Format und Auflösungswarnungen. +- Erkennung möglicher Duplikate über gleiche SKU und Titelähnlichkeit. +- Plattformbereitschaft, Gebühren und geschätzter Erlös gemeinsam im Prüfbericht. +- Persistenter Score-Verlauf je Artikel mit Vergleich zur vorherigen Analyse. +- Filter, Suche und Pagination für bereite, blockierte und zu prüfende Artikel. +- AI-Verbesserungen für Titel, Beschreibung und Tags über den aktuell aktiven Text-AI-Provider. +- AI erzeugt ausschließlich Vorschläge; Änderungen werden erst nach sichtbarem Vorher-/Nachher-Vergleich und ausdrücklicher Bestätigung gespeichert. +- Keine automatische Erfindung von Produktmerkmalen, Marken, Schäden oder Lieferumfang. + +## 1.27.0 – Slice 32: Batch Image Factory + +- Neuer Hauptmenüpunkt **Bildproduktion** als persistente Batch Image Factory. +- Mehrere Quellbilder direkt aus der zentralen Bildergalerie auswählen oder aus der Galerie übergeben. +- Acht integrierte Produktionsprofile für eBay, Vinted, Kleinanzeigen, Etsy, Instagram, Webshop und optimierte Originale. +- Eigene Produktionsprofile mit Zielgröße, Anpassung, Hintergrund, Format, Qualität und Dateinamensschema anlegen, bearbeiten und löschen. +- Mehrere Profile pro Auftrag erzeugen mehrere Ausgaben je Originalbild. +- Optionale Hintergrundentfernung mit Intensität sowie Wasserzeichen mit Text, Position und Deckkraft. +- Persistentes Job-Center mit 5/10/15 Aufträgen pro Seite, Pause, Fortsetzen, Abbruch, Gesamt-Retry und Einzel-Retry. +- Neustart-Wiederaufnahme für wartende und laufende Aufträge; pausierte Aufträge bleiben pausiert. +- Ergebnisse werden nicht destruktiv als neue Bildversionen unter `uploads/batch/` gespeichert und erscheinen automatisch in der Bildergalerie. +- Fertige Ergebnisse eines Auftrags können gesammelt als ZIP heruntergeladen werden. +- Bildrenderer um `contain`, `cover`, exakte Ziel-Canvas, Ausgabeordner und kontrollierte Dateinamen erweitert. + +## 1.26.1 – Stabilitäts-, Responsive- und Regression-Gate + +- Zentrale Systemprüfung im Adminbereich ergänzt: SQLite-Integrität, Schreibrechte, UI-Dateien, FLUX-Workflow, Browser-Extensions, AI-Konfiguration und optionale ComfyUI-Erreichbarkeit. +- Öffentlichen, geheimnisfreien `/healthz`-Endpunkt für lokale Verbindungsüberwachung ergänzt. +- API-Client um Request-IDs, definierte Zeitlimits, einmaligen GET-Retry bei Netzwerk-/Serverfehlern und strukturierte Fehlerprotokollierung erweitert. +- Sichtbare Verbindungsleiste bei Serverausfall mit automatischer Wiederholungsprüfung ergänzt. +- Unbehandelte JavaScript- und Promise-Fehler werden lokal mit Build, Seite und Request-ID für die Diagnose erfasst. +- Responsive-Verhalten auf die tatsächlich verfügbare Workspace-Breite umgestellt; `ResizeObserver` vergibt Breitenklassen unabhängig von der Browserbreite. +- Globale Schutzregeln gegen horizontales Überlaufen von Formularen, Bildern, Vorschauen, Werkzeugleisten und langen Inhalten ergänzt. +- Neue statische Release- und UI-Vertragsprüfungen verhindern doppelte IDs, tote Navigation, fehlende Diagnoseelemente und veraltete Buildmarker. +- Keine Datenbankstruktur, Nutzerdaten, API-Schlüssel, Extensions oder FLUX-Modelle verändert. + +## 1.26.0 – Artikel-Navigation, Publish-Reparatur, Zoom-Galerie & Extension Center + +- „Neues Listing“ konsequent in „Neuer Artikel“ und „Listing generieren“ in „Artikel generieren“ umbenannt. +- „Neuer Artikel“ als eigener Hauptmenüpunkt direkt unter dem Dashboard ergänzt; „Listings“ heißt in der Navigation jetzt „Artikel“. +- Publish-Datenaufbereitung gegen fehlerhafte ältere JSON-Felder gehärtet und einen Scope-Fehler im direkten Publish-Workspace behoben. +- Smart Copy öffnet wieder zuverlässig als Popup; die mittlere Publish-Fläche zeigt Titel, Beschreibung, Tags, Metadaten und Fotos. +- Medienvorschau um Fit-, 100-%-, Stufen- und Mausrad-Zoom sowie Verschieben bei Vergrößerung erweitert. +- Galerie-Auswahl und Favoriten auf kleine, konsistente Lucide-basierte SVG-Icons umgestellt. +- Gebührenrechner für private Verkäufer aktualisiert: eBay.de, Vinted und normaler Kleinanzeigen-Privatverkauf ohne Verkäuferprovision; Etsy separat als Shop-Kalkulation. +- Neuer Menüpunkt „Extensions“ mit Browsererkennung, Ein-Klick-Vorbereitung, Pfadkopie und Downloadpaketen für Chrome, Edge, Firefox und Safari. +- Safari-WebExtension-Quelle samt Xcode-Konvertierungsskript ergänzt. +- Doppelte Settings-ID und doppelten Galerie-Event-Listener bereinigt. + +## 1.25.1 – Adaptive Bildergalerie + +- Galerie-Raster vollständig auf automatische Kartenbreiten mit `auto-fit` und Container Queries umgestellt. +- Die gesamte Bildfläche öffnet jetzt die Lightbox; die übergroße Vorschau-/Öffnen-Schaltfläche wurde entfernt. +- Auswahl und Favorit skalieren mit der Kartenbreite und bleiben sauber in den Bildecken ausgerichtet. +- Aktionsleiste auf vier kompakte, adaptive Bildaktionen umgestellt; auf sehr schmalen Karten werden nur Symbole gezeigt. +- Auswahlstatus wird zusätzlich an Karte und Bildfläche sichtbar, ohne das Motiv zu verdecken. +- Seitennavigation mit direkter Seitenauswahl und Bildbereich wird oberhalb und unterhalb der Galerie angezeigt. +- Listenansicht und schmale Bildschirmbreiten wurden im selben Komponentenmodell neu gehärtet. + +## 1.25.0 – Responsive Detail Editor, Gallery UX, Smart Copy Modal & FLUX Prompt Lab + +- Listing-Bearbeiten reagiert jetzt auf die tatsächlich verfügbare Inhaltsbreite per Container Queries und bleibt auch mit Sidebar, Quill, Vorschau und Kontextkarten ohne horizontalen Überlauf bedienbar. +- Die zentrale Bildergalerie erhielt sauber ausgerichtete Auswahlfelder, Favoriten-Overlay, eine eindeutige Vorschauaktion, zweizeilige Titel und ein stabiles 2×2-Aktionsraster. +- Publish-Aktualisieren rotiert nicht mehr; während des Ladens werden ruhige Textzustände und `aria-busy` verwendet. +- Smart Copy öffnet wieder als eigenes responsives Arbeitsfenster mit Kopierfeldern, Fotos, Plattformstart und Veröffentlichungsstatus. +- FLUX Ergebnisse & Favoriten besitzen Pagination mit 5, 10 oder 15 Bildern pro Seite. +- Das FLUX Job-Center ist auf 5, 10 oder 15 Aufträge pro Seite begrenzt. +- Neues FLUX Prompt Lab: lokale Strukturierung oder AI-Veredelung über den aktuell aktiven Claude-, OpenAI-, OpenRouter- oder Ollama-Provider. +- Prompt Lab schützt auf Wunsch Motivmerkmale und vermeidet erfundene Texte, Logos und Wasserzeichen. +- Zusätzliche Breitenhärtung für Publish, Medienbibliothek, FLUX, Einstellungen und Generator. + +## 1.24.0 – Responsive UI, FLUX-Auftragsarchiv & zentrale Bildergalerie + +- Listing-Seite auf schmalen Breiten von einer starren Mindestbreite auf echte responsive Kartenzeilen umgestellt. +- Responsive Härtung für Filter, Batch-Aktionen, FLUX-Job-Center, Formulare, Aktionsleisten und Medienverwaltung ergänzt. +- Hintergrundentfernung im Advanced Editor um eine regelbare Intensität von 0 bis 100 erweitert. +- FLUX-Aufträge können einzeln oder gesammelt archiviert und aus dem Archiv wiederhergestellt werden. +- Im FLUX-Archiv können einzelne, mehrere oder alle Aufträge endgültig gelöscht werden; erzeugte Bilder bleiben erhalten. +- Serverseitige Pagination und Seitengröße für aktive und archivierte FLUX-Aufträge ergänzt. +- Neuer Navigationsbereich „Bilder“ als zentrale Galerie für FLUX-, bearbeitete, AI- und Listing-Bilder. +- Galerie mit Raster-/Listenansicht, Suche, Quellenfiltern, Favoriten, Sortierung, Pagination, Mehrfachauswahl, ZIP-Download, Lightbox, Editor-Übergabe und geschützter Löschung aktiver Listing-Bilder. +- Vinted-Titelfeldschutz aus Vendoo Link 1.6.1 unverändert beibehalten. + +## 1.23.2 – Slice 31.2 Hotfix: FLUX-Layout, Editor-Werkzeuge & kompakte Aufträge + +- „System & ComfyUI“ aus der angehefteten FLUX-Seitenspalte entfernt und direkt unter „Prompt & Auftrag“ eingeordnet. +- FLUX Job-Center als Ganzes ein- und ausklappbar gemacht. +- Einzelne FLUX-Aufträge kompakt auf- und zuklappbar; aktive Aufträge sind standardmäßig geöffnet, abgeschlossene kompakt geschlossen. +- „Alle einklappen“ ergänzt, damit lange Verläufe die Seite nicht unnötig vergrößern. +- Separate Buttons für Hintergrundentfernung und Wasserzeichen aus Generator und Bildleisten entfernt. +- Hintergrundentfernung in den Advanced Image Editor integriert und als nicht destruktive Bildversion gespeichert. +- Wasserzeichen mit eigenem Text, fünf Positionen und einstellbarer Deckkraft in den Advanced Image Editor integriert. +- Editor-Werkzeuge gelten einheitlich für Generator-, Listing- und FLUX-Bilder. +- Vendoo Link 1.6.1 und der Vinted-Titelfeldschutz bleiben unverändert erhalten. + +## 1.23.1 – Slice 31.1 Hotfix: Editor-Sichtbarkeit & Vinted-Feldschutz + +- Advanced Image Editor erhält im Generator einen deutlich sichtbaren eigenen Einstieg. +- `app.js` und `style.css` werden versionsgebunden geladen; HTML/JS/CSS werden vom Vendoo-Server nicht mehr aus veraltetem Browser-Cache ausgeliefert. +- Der Windows-Starter öffnet Vendoo mit Build-Kennung. +- Vinted-Auto-Fill ordnet Felder nur noch über eindeutige Selektoren bzw. tatsächlich zugeordnete Labels zu. +- Zustands-, Marken-, Größen- und Farbauswahl dürfen das Titelfeld nicht mehr als Fallback verwenden. +- Vendoo Link auf Version 1.6.1 angehoben. + +# Changelog + +## 1.23.0 – Slice 31: Advanced Image Editor + +- Bestehenden einfachen Fotoeditor durch einen nicht destruktiven Produkt- und FLUX-Bildeditor ersetzt. +- Undo/Redo und eine vollständige Bearbeitungshistorie innerhalb der aktuellen Sitzung ergänzt. +- Persistente Bildversionen in `image_edit_versions` eingeführt; das Original bleibt unverändert und jederzeit wiederherstellbar. +- Vorher-/Nachher-Ansicht und Auswahl älterer Bearbeitungsversionen integriert. +- Freien Zuschnitt sowie 1:1, 4:5, 3:4, 16:9 und Original-Seitenverhältnis ergänzt. +- Drehen, horizontales/vertikales Spiegeln und stufenloses Begradigen umgesetzt. +- Belichtung, Helligkeit, Kontrast, Lichter, Schatten, Temperatur, Sättigung und Schärfe ergänzt. +- Export als JPEG, WebP oder PNG mit Qualitätsregler und Zielgröße umgesetzt. +- Bearbeitungen werden serverseitig mit `sharp` in voller Auflösung gerendert und immer als neue Datei gespeichert. +- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor bleiben bewusst außerhalb dieses Slices; es gibt keine Platzhalterbuttons. + +## 1.22.0 – Slice 30: FLUX Studio Pro + +- Persistentes FLUX-Job-Center mit SQLite-Aufträgen und einzelnen Varianten eingeführt. +- 1, 2 oder 4 Varianten, eindeutige Seeds und reproduzierbarer Seed Lock ergänzt. +- „Ähnlich erneut erzeugen“, Favoriten und vollständige Prompt-/Parameterhistorie umgesetzt. +- Abbruch, Wiederholen, Queue-Bereinigung und Neustart-Wiederaufnahme ergänzt. +- ComfyUI-Warteschlange, Historie, System-, GPU- und VRAM-Daten angebunden. +- Fortschrittsanzeige des FLUX Studios auf reale Phasen ohne geschätzte Prozentwerte umgestellt. +- Freier FLUX-Promptworkflow bleibt ohne Produktreferenz und ohne `LoadImage`. +- FASHN VTON und Virtual Try-on bleiben vollständig entfernt. + +## 1.21.0 – Slice 29: FLUX Quality & Settings Tabs + +- Einstellungsbereiche als horizontale Top-Tabs neu gestaltet. +- FLUX Studio mit Prompt-Check, lokalem Prompt-Assistenten und Qualitätsprofilen erweitert. +- FLUX.1-schnell standardmäßig auf vier Schritte optimiert; Detailprofil mit sechs Schritten. +- Negative Prompt im reinen FLUX-Promptmodus entfernt. +- Startprofile Auto, Schnell, Ausgewogen und Wenig VRAM ergänzt. +- ComfyUI-Start verwendet je nach Profil Cache-/VRAM- und optionale Fast-Flags. +- Laufzeit und final strukturierter Prompt werden je Bild gespeichert. +- Vorschau und Bearbeitungsaktionen visuell aufgewertet. + +## 1.20.1 – FLUX Runtime Startdiagnose und Health-Check + +- FLUX/ComfyUI-Start wartet jetzt bis zu 180 Sekunden auf den echten `/system_stats`-Health-Check statt nach 2,5–5 Sekunden vorschnell „gestoppt“ zu melden. +- Eigene Runtime-Statusdatei mit PID, Laufzeit, Startphase sowie bereinigten stdout-/stderr-Auszügen. +- ANSI-Farbcodes werden aus den angezeigten Runtime-Logs entfernt. +- Prozessabbruch während des Starts wird sofort erkannt und mit den letzten relevanten Logzeilen gemeldet. +- Das reine Vendoo-FLUX-Studio startet ComfyUI ohne benutzerdefinierte Nodes; der verwendete Workflow benötigt ausschließlich eingebaute Standard-Nodes. +- Start- und Stopp-Skripte liegen jetzt als feste Paketdateien vor und werden beim Drüberkopieren sofort aktualisiert. +- FLUX Studio und Einstellungen zeigen während des Starts laufend „Wird gestartet“ statt einen falschen Stoppstatus. + +## 1.20.0 – FASHN/VTO entfernt und eigenständiges FLUX Studio + +- FASHN VTON, Modelbibliothek, lokale VTO-Dienste, API-Routen, UI, Installationsskripte und Einstellungen vollständig aus dem aktiven Produkt entfernt. +- Bestehende VTO-Runtime, Autostarts, Logs und alte VTO-Einstellungen werden beim ersten Setup-Lauf entfernt; eigene alte Modelbilder werden nach `uploads/ai-generated/legacy-models` gesichert. +- Neuer Hauptmenüpunkt **FLUX Studio** mit freiem Prompt, Stil, Format, Seed und Schritten. +- Live-Status und Start/Stopp-Steuerung für das lokale ComfyUI/FLUX. +- Vorschau, Download, Bildbibliothek, Löschen und Übernahme in den Listing-Generator. +- Nachträglicher Bildeditor für Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung. Bearbeitungen werden als neue Version gespeichert. +- Neue Tabelle `flux_images`; aufgegebene VTO-Tabelle und `ai_vto_*`-Einstellungen werden aus bestehenden Datenbanken entfernt. +- FLUX bleibt ein reiner Prompt-Bildgenerator; Ghost Mannequin und Flatlay bleiben als separate Produktbild-Werkzeuge bestehen. + +## [1.15.0] - 2026-07-07 + +### Neu + +- Komplett neu strukturierte Einstellungszentrale mit Bereichen für Allgemein, AI & APIs, Marktplätze und lokales FLUX. +- Großer FLUX-Live-Status mit klarer Anzeige für Aktiv, Gestoppt, Deaktiviert und Nicht installiert. +- Lokales FLUX kann direkt aktiviert, deaktiviert, gestartet, gestoppt, repariert, aktualisiert und sicher deinstalliert werden. +- Sichere FLUX-Deinstallation entfernt ComfyUI und das Modell, behält standardmäßig aber die Installationsarchive für eine schnelle Neuinstallation. +- Einstellbare Produktreferenz-Treue für AI-Model-Fotos. +- Neuer Geschwindigkeitsmodus mit 768 × 768 Pixeln als Standard; 1024 × 1024 bleibt als Qualitätsoption verfügbar. + +### Behoben + +- Produktfotos werden bei lokalem FLUX jetzt tatsächlich an ComfyUI übertragen und als Image-to-Image-Referenz in den Workflow eingebunden. +- Hoodie, Farbe, Kapuze, Reißverschluss, Aufdrucke, Logos und Schnitt werden im Prompt zusätzlich als verbindliche Produktmerkmale geschützt. +- AI-Bildjobs aktualisieren ihren Fortschritt jetzt während Vorbereitung, Upload, Rendering, Sicherheitsprüfung und Speicherung statt dauerhaft bei 10 % zu stehen. +- Alte oder beschädigte Installationsstatusdateien können nicht mehr zu `unknown · Bereitschaft 100%` führen, wenn die reale Installation vollständig vorhanden ist. +- Der konfigurierte Installationspfad `local-ai/comfyui` wird korrekt auf den enthaltenen Portable-Ordner aufgelöst. + +### Technik + +- Neue ComfyUI-Referenzpipeline: UploadImage → LoadImage → ImageScale → VAEEncode → KSampler. +- Neue Datenbankspalte `ai_model_jobs.progress_message` als additive Migration. +- BOM-sicheres Lesen des FLUX-Installationsstatus. +- Neuer PowerShell-5.1-kompatibler Uninstaller `scripts/uninstall-local-flux.ps1`. + +## [1.14.6] - 2026-07-07 + +### Behoben + +- Lokaler FLUX-Workflow wird unter Windows PowerShell 5.1 als UTF-8 ohne BOM geschrieben. +- Bereits vorhandene Workflow-Dateien mit UTF-8-BOM werden beim Einlesen robust bereinigt. +- `Unexpected token '' ... is not valid JSON` bei der lokalen AI-Bildgenerierung behoben. +- Installer-Status-JSON wird ebenfalls ohne BOM geschrieben, damit Node.js und andere JSON-Parser es zuverlässig lesen können. + +# 1.14.5 – Slice 22.3 PowerShell 5.1 Parser Fix + +- Behebt den PowerShell-5.1-Parserfehler `InvalidVariableReferenceWithDrive` im sichtbaren FLUX-Downloadstatus. +- Ersetzt die beiden ungültigen Zeichenfolgen `$Label:` durch die eindeutig begrenzte Form `${Label}:`. +- Der bestehende Resume-Download, die `.part`-Datei und der automatische Setup-Ablauf bleiben unverändert erhalten. +- Keine Datenbank-, Konfigurations-, Upload- oder Listing-Daten werden verändert. + +# 1.14.4 – Slice 22.2 Download Recovery + +- Fortsetzbarer HTTP-Streaming-Download statt BITS/Invoke-WebRequest. +- Sichtbare Fortschrittsanzeige mit Prozent, MB, Geschwindigkeit und Restzeit. +- Automatische Wiederaufnahme über setup.bat. + +# Changelog + +## Slice 22.1 / 1.14.3 – Setup-integrated PowerShell 5.1 recovery +- Fixed the false 7-Zip failure when `Process.ExitCode` is empty under Windows PowerShell 5.1 despite a successful extraction. +- A missing exit code is accepted only when `Everything is Ok` is present, stderr is empty and the extracted result passes file-count and size checks. +- Complete `local-ai\extract-temp` results are reused instead of being deleted and extracted again. +- `setup.bat` now starts with UTF-8 console handling, `-NoProfile` and explicit exit-code reporting. +- Normal `setup.bat` startup detects the exact confirmed Slice-22 false-failure state and offers automatic continuation. +- Automatic continuation installs ComfyUI, downloads the FLUX.1-schnell model and starts the local image server without a separate patch command. +- Installer intent flags are persisted in `install-state.json` for future resumable runs. +- No reset, database change or feature removal is performed. + +## Slice 22 / 1.14.2 – 7-Zip extraction watchdog and real FLUX install telemetry +- Replaced the blocking 7-Zip invocation with a supervised process and explicit path quoting. +- Live extraction status now includes elapsed time, extracted file count, extracted MB, process ID and heartbeat. +- Added 7-Zip stdout/stderr logs and a visible UI panel for extraction details. +- Detects stalled installers instead of showing an endless running state. +- Reuses complete downloads and safely restarts only the failed extraction/install step. +- Handles nested ComfyUI portable archive layouts more robustly. +- Setup status now shows live extraction counters and stale heartbeat detection. + +## Slice 21 – FLUX Installer 7-Zip Hotfix & Detailed Status + +- Fixed PowerShell 5.1 scalar-string bug where a single 7-Zip path such as `C:\Program Files\7-Zip\7z.exe` was indexed as the single character `C`. +- 7-Zip discovery now uses a real string list, refreshes PATH after winget installation and validates the final executable path. +- Partial ComfyUI and FLUX downloads now use `.part` files and are validated before being promoted to complete files. +- BITS downloads now report actual transfer percentage, bytes transferred and total bytes into `local-ai/install-state.json`. +- Installer failures preserve the last real progress value and include source position plus resumable-state metadata. +- Settings UI now shows detailed checks for 7-Zip, archive, Python runtime, ComfyUI, FLUX model, workflow and start/stop scripts. +- Install log tail, error details, readiness score and a resume-aware install button were added. +- Setup option 8 now prints phase, progress, file checks, runtime reachability and recent install log lines. + +## Slice 20 – Vollautomatischer lokaler FLUX-Installer + +- Automatischer Download der aktuellen offiziellen ComfyUI-Windows-Portable-Version über die GitHub-Releases-API +- GPU-Erkennung für NVIDIA, AMD, Intel und CPU-Fallback +- Automatische 7-Zip-Installation über winget, falls erforderlich +- Optionaler Download von FLUX.1-schnell FP8 aus dem offiziellen Comfy-Org-Hugging-Face-Repository +- Fortschritts- und Statusdatei unter `local-ai/install-state.json` +- Fortsetzbare Downloads über BITS mit WebRequest-Fallback +- Sichere Startparameter: Bindung an `127.0.0.1`, kein Browser-Autostart, eigener Port 8188 +- Optionale Windows-Autostart-Aufgabe `Vendoo Local FLUX` +- UI-Aktionen für Installieren, Aktualisieren, Starten, Stoppen und Status prüfen +- Setup-Menü schützt weiterhin vor versehentlicher Neuinstallation von Vendoo und erkennt vorhandene FLUX-Installationen +- Provider-Reihenfolge im Auto-Modus: lokal FLUX → OpenRouter Free → OpenAI + +## Slice 19 – Local FLUX / ComfyUI Bootstrap, safer setup install flow +- Setup hardened: Install checks whether Vendoo is already installed and suggests Start / Update / Repair instead of blindly reinstalling. +- New setup options: local FLUX / ComfyUI bootstrap and status checks. +- New local image provider architecture for AI model photos: Auto now supports Local FLUX -> OpenRouter Free -> OpenAI. +- New server routes for local image status, bootstrap install and token regeneration. +- Settings UI expanded with local FLUX URL, token, workflow path and install path. +- Added local workflow bootstrap template and helper scripts under `scripts/` and `local-ai/workflows/`. + +## Vendoo 1.12.0 – OpenRouter Diagnostics & Mobile Mediathek + +- Mobiler QR-Upload besitzt jetzt getrennte Aktionen für **Kamera** und **Mediathek** +- Mehrfachauswahl aus der Smartphone-Mediathek ohne `capture`-Zwang +- HEIC/HEIF werden beim mobilen Upload nach Möglichkeit automatisch in JPG konvertiert +- OpenRouter-Free-Erkennung bewertet nur bildrelevante Preisfelder +- OpenRouter versucht je Modell mehrere kompatible Chat-Request-Varianten und danach die Images API +- Referenzbilder werden nur an Modelle gesendet, die Bildinput unterstützen +- Klare Diagnose, wenn aktuell **0 kostenlose Bildmodelle** verfügbar sind +- Bei Fehlern werden weiterhin alle passenden Free-Modelle nacheinander versucht; Auto fällt anschließend auf OpenAI zurück + +## Vendoo 1.11.0 – LAN QR Upload, OpenRouter Fallback & Mobile Nav Fix + +- QR-Handy-Upload verwendet automatisch eine erreichbare LAN-IP statt localhost +- Vendoo lauscht für lokale Netzwerkzugriffe auf `0.0.0.0` und zeigt erkannte LAN-Adressen beim Start an +- Neue Einstellung für `PUBLIC_BASE_URL` inklusive automatischer LAN-IP-Erkennung +- OpenRouter probiert bei Fehlern automatisch weitere passende Free-Bildmodelle und danach – im Auto-Modus – OpenAI +- OpenRouter-Bildgenerierung unterstützt Chat-Completions und Images-API als zweistufigen Fallback +- Mobile Navigation vollständig gehärtet: Overlay, Close-Button, Fokus, Escape, Scroll-Lock und responsive Labels + +## Vendoo 1.10.0 – AI Jobs, 1–3 Varianten & History + +- AI-Bildgenerierung läuft jetzt über eine Job-Queue statt nur synchron +- Mindestanzahl auf **1 Bild** gesenkt, Maximum bleibt **3 Bilder** +- Neue Modi: **Model**, **Ghost Mannequin**, **Flatlay** +- Listing-Editor zeigt jetzt eine **AI-Bild-Historie** zum schnellen Übernehmen früherer Generierungen +- Settings erweitert um Standard-Modus für AI-Bilder + +## Vendoo 1.9.0 – AI Provider Settings Center + +- Neuer Settings-Bereich für AI-Model-Fotos mit Provider-Steuerung +- Admin kann jetzt `auto`, `openrouter` oder `openai` für Bildgenerierung wählen +- OpenRouter-Free-Modelle können direkt aus der UI geladen und geprüft werden +- Safety-Flags, Standard-Preset, Variantenanzahl und Fallback-Modell sind in der UI konfigurierbar + +## Vendoo 1.8.0 – OpenRouter Free Image Discovery + +- AI-Model-Fotos unterstützen jetzt einen Provider-Modus `auto` +- OpenRouter Free-Bildmodelle werden dynamisch über die Models-API abgefragt und bei Verfügbarkeit bevorzugt genutzt +- Fallback auf OpenAI (`gpt-image-1` oder konfiguriertes Modell), wenn keine freien OpenRouter-Modelle verfügbar sind +- Neue API-Route für verfügbare OpenRouter-Bildmodelle sowie Persistenz von Provider und Modell je Generierung + +## Vendoo 1.7.0 – AI-Model-Fotos & Safety Layer + +- Neuer sicherer AI-Model-Foto-Workflow für Kleidung ohne Person auf dem Originalfoto +- 2–3 KI-generierte Zusatzbilder mit fiktiven erwachsenen Models in Studio-, Indoor- oder Outdoor-Looks +- Precheck & Output-Safety: blockiert Unterwäsche, Bademode, transparente Erotik-Looks, Kinderkleidung und Quellen mit Personen +- Neue Backend-Routen, Datenbanktabellen und Listing-Integration für Generator und Detailansicht + +# Changelog ## [Unreleased] +## [1.6.2] - 2026-07-07 + +### Branding + +- neues Vendoo-Hauptlogo in die App-Sidebar integriert +- neues Branding auf Login und mobilem QR-Fotoupload integriert +- vollständiges Favicon-Paket mit ICO, PNG, Apple Touch Icon und Webmanifest eingebunden +- mobile App-Icon-Größen von 16 bis 512 Pixel ergänzt +- Chrome-, Edge- und Firefox-Erweiterungen auf die neue Vendoo-Icon-Familie aktualisiert +- Vinted Assistant und minimierter Assistant verwenden das neue Logo + +## [1.6.1] - 2026-07-07 + +### Verbessert + +- Dashboard ist jetzt der erste Menüpunkt und die Standard-Startseite. +- Dashboard-KPI-Karten verwenden eine fließende Auto-Fit-Struktur statt starrer Sechserspalten. +- Sparklines besitzen eine reservierte Diagrammfläche und überlagern keine Texte mehr. +- Umsatzdiagramme werden bei Größenänderungen automatisch neu berechnet. +- Dashboard-Charts passen sich an Notebook-, Tablet- und Mobilbreiten an. +- Studio-Flow-Karten verwenden proportionale Bildflächen mit vollständiger `contain`-Darstellung. +- Produktbilder werden nicht mehr abgeschnitten und erhalten einen sauberen Fehlerzustand. +- Ready-Karten verteilen sich automatisch auf die verfügbare Breite. + +## [1.6.0] - 2026-07-07 + +### Hinzugefügt + +- neuer Sidebar-Menüpunkt **Dashboard** mit interaktiven Kennzahlen, Umsatzverlauf, Plattform- und Kategorieauswertung +- Dashboard-Bereiche für Fokusaufgaben, Top Seller, Aktivität, Benachrichtigungen, AI-Qualität und Systemstatus +- Fotoarchiv-Endpunkt: mehrere Fotos werden serverseitig als gültiges ZIP erzeugt +- Foto-Download im Generator und vollständigen Listing-Editor + +### Geändert + +- Studio Flow vollständig neu strukturiert: Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- die fachlich unpassenden sichtbaren Stufen „Neu erfasst“ und „AI-Prüfung“ wurden entfernt +- Studio Flow zeigt nur priorisierte Artikel statt einer überfüllten Spaltenwand +- geplante Listings, Probleme und Aktivitäten sind in klar getrennten Arbeitsbereichen organisiert +- Dashboard und Studio Flow für Notebook, iPad, Tablet und Smartphone neu priorisiert + +### Behoben + +- Publish-Center lädt Listings beim ersten Öffnen mit Wiederholungslogik und verhindert veraltete parallele Renderings +- Publish-Ladefehler zeigt jetzt eine konkrete Wiederholen-Aktion statt einer Sackgasse +- bei einem Foto erfolgt ein normaler Download, bei mehreren Fotos automatisch ein ZIP-Download + +## [1.5.0] - 2026-07-07 + +### Hinzugefügt + +- Browser-Erweiterungen in getrennten Ordnern für Chrome, Edge und Firefox +- vollständiger Design-Relaunch von Popup und Vinted Assistant +- neues Vendoo-Link-Icon in 16, 32, 48 und 128 Pixel +- QR-basierter Handy-Fotoupload für Generator und Listing-Editor +- sichere, zeitlich begrenzte Mobile-Upload-Sitzungen +- responsive Layout-Gates für Desktop, Notebook, iPad und Smartphone +- umschaltbare Plattformvorschau und HTML-Codeansicht + +### Verbessert + +- Produktbilder der Extension werden vollständig dargestellt +- CSRF-Unterstützung für schreibende Extension-Requests +- öffentliche Serveradresse statt festem localhost für Extension-Bilder +- Plattformvorschau und HTML-Code sind in Generator und Listing-Editor als Umschalter zusammengeführt +- Layouts für Desktop, Notebook, iPad und Smartphone neu priorisiert +- freigegebene Extension- und Icon-Mockups als verbindliche Designreferenz archiviert + +### Behoben + +- Studio Flow: „Prüfen & bearbeiten“ wechselt jetzt zuverlässig zur sichtbaren Listing-Detailansicht. +- Detailaufrufe aus Attention Queue und anderen Bereichen verwenden denselben sicheren Navigationspfad. +- Ungültige Listing-IDs werden abgefangen und als Hinweis angezeigt. + +- Windows-Installer behandelt npm `ENOTEMPTY`/`EPERM` robust, bereinigt unvollstaendige `node_modules`, protokolliert npm-Ausgaben und wiederholt die Installation einmal. +- Installation nutzt mit vorhandenem Lockfile reproduzierbar `npm ci` statt eines stillen `npm install`. + ### Geplant -- vollständiger Import des lokalen Vendoo-Quellprojekts -- Docker- und Online-Readiness-Foundation -- sichere Server-Konfiguration und Healthchecks -- Mandantenfähigkeits- und Rechtekonzept für externe Benutzer +- Lager, Vorlagen, Einstellungen und Admin gemäß Design-Freeze +- Docker- und Online-Readiness -## [1.0.0-baseline] - 2026-07-07 +## [1.4.0-full-listing-editor] - 2026-07-07 -### Dokumentiert +### Hinzugefügt -- bestehender Funktionsumfang mit 17 Feature-Bereichen -- verbindlicher Tech Stack und Projektstruktur -- Sicherheits- und Repository-Grundregeln -- privates GitHub-Repository als Versionsbasis +- Listings lassen sich jetzt vollständig in einem eigenen Arbeitsbereich bearbeiten +- Fotoverwaltung mit Hinzufügen, Entfernen, Cover-Auswahl, Drag-to-Sort und vollständigem Bildeditor ergänzt +- WYSIWYG-Editor und HTML-Code sind im Listing-Editor bidirektional und live synchronisiert +- Generator zeigt HTML-Code und Plattformvorschau gleichzeitig; Änderungen werden ohne Umschalten übernommen +- Plattform-HTML wird während der Bearbeitung live neu gerendert +- neue Datenbankspalte `description_html` mit Migration und Rückwärtskompatibilität +- neuer API-Endpunkt `POST /api/html-render` für nicht persistierende Live-Vorschauen +- Editor-HTML und vollständiges Plattform-HTML können getrennt kopiert werden + +### Sicherheit + +- Rich-Text-HTML wird client- und serverseitig auf eine begrenzte Tag- und Attributmenge reduziert +- Plattformvorschauen laufen in sandboxed iframes +- Listing-Updates verwenden jetzt eine feste Feld-Allowlist statt beliebiger SQL-Spaltennamen + +### Behoben + +- `photos` werden bei Listing-Updates korrekt als JSON gespeichert +- Bildbearbeitung funktioniert nun auch für bereits gespeicherte Listings +- formatierte Beschreibungen bleiben nach Speichern und erneutem Öffnen erhalten + +## [1.3.0-publish-center] - 2026-07-07 + +### Geändert + +- Publish-Bereich als durchgängiges operatives Publish-Center neu aufgebaut +- Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung in einer gemeinsamen Oberfläche zusammengeführt +- bildorientierte Listing-Auswahl mit Suche, Plattform-/Statusfilter, Sortierung und Mehrfachauswahl ergänzt +- internes Workspace-Modell statt separater Queue- und Smart-Copy-Modale eingeführt +- direkte Listing-Bearbeitung vor Etsy-/eBay-Veröffentlichung integriert +- Publish-Bereitschaft, Pflichtfelder, Integrationsstatus und Fotoprüfung sichtbar gemacht +- Queue-Status, Schnellaktionen und Publish-Aktivität in einer festen Statusleiste gebündelt +- eBay-Fehler, Wiederholungen und geplante Veröffentlichungen inline bedienbar gemacht +- responsive Darstellung und Dark Mode für das komplette Publish-Center ergänzt + +### Behoben + +- Batch-Smart-Copy öffnete denselben Vorgang teilweise doppelt +- nicht passende Etsy-Batch-Auswahl wird jetzt gefiltert statt blind veröffentlicht +- Produktbilder im Publish-Center werden vollständig und ohne Beschnitt dargestellt +- Queue- und Planungsaktionen bleiben im Vendoo-Kontext und verlieren nicht den aktuellen Arbeitsstand + +## [1.2.0-listing-studio] - 2026-07-07 + +### Geändert + +- Listing-Generator als dreigeteilter Studio-Arbeitsplatz neu aufgebaut +- Foto-Cover, Sortierung, Qualitätscheck und vollständige Bildanpassung integriert +- AI-Konfiguration mit Plattform, Provider, Modell, Sprache, Varianten und Vorlagen neu strukturiert +- artikelspezifische Verkäufernotizen mit Zeichenzähler ergänzt +- Ergebnisbereich mit Live-Zählern, Qualitätswert, Attributen, Gebühren und HTML-Vorschau modernisiert +- responsive Darstellung und Dark Mode für den Generator vollständig ergänzt + +### API + +- `POST /api/generate` akzeptiert `template_id` und `seller_notes` +- globale Hinweise, Vorlagenhinweise und Artikelhinweise werden dedupliziert kombiniert +- Standard-Tags aus Vorlagen werden mit AI-Tags zusammengeführt + +### Behoben + +- Upload begrenzt neue Bilder clientseitig auf die verbleibenden Plätze bis maximal zehn +- Upload-Fehler werden mit HTTP-Status und Servermeldung korrekt angezeigt +- Dateinamen in Vorschaubildern werden URL-sicher ausgegeben + +## [1.0.1-ui-foundation.1] - 2026-07-07 + +### Hinzugefügt + +- zentrale Vendoo-Design-Tokens für Light und Dark Mode +- neue kompakte App-Shell und Hauptnavigation +- Command-Search-Einstieg mit `Ctrl/⌘ + K` +- neue Startseite `Studio Flow` +- Flow-Stufen: Neu erfasst, AI-Prüfung, Bereit, Geplant, Veröffentlicht, Verkauft +- Attention Queue und Activity Stream +- kontextueller Artikel-Inspector +- neuer Endpunkt `GET /api/dashboard/workflow` +- Dokumentation `docs/UI_FOUNDATION_2026.md` +- sicherer `.gitignore` + +### Sicherheit + +- `.env`, SQLite-Laufzeitdateien, Uploads und `node_modules` aus dem Übergabepaket entfernt +- keine produktiven Zugangsdaten in der bereinigten Version + +## [1.1.1-ui-polish] - 2026-07-07 + +### Geändert + +- Etiketten und Artikeldatenblätter öffnen jetzt im internen Vendoo Print Studio statt in einem neuen Browserfenster +- maßstabsgetreue A4-Vorschau mit zwei Etikettenformaten, Exemplaren und Preisoption +- Druck erfolgt im aktuellen Vendoo-Fenster über den normalen Systemdruckdialog +- Studio-Flow-Produktbilder verwenden vollständige automatische Anpassung (`object-fit: contain`) +- Inspector- und Attention-Bilder werden ebenfalls ohne Beschnitt dargestellt +- Vinted-Extension auf die freigegebene Vendoo-Designsprache 2026 umgestellt +- Extension-Popup und eingeblendetes Vinted-Panel modernisiert +- Produktbilder der Extension werden sicher über den Background-Service-Worker als Data-URL geladen +- Pfade von Upload-Bildern werden korrekt URL-codiert und Bildfehler erhalten einen sichtbaren Zustand +- fehlerhaft doppelt codierte deutsche Texte in der Extension repariert + +### Behoben + +- Produktvorschaubilder in der Vinted-Extension wurden durch Seiten-CSP/Mixed-Content-Konstellationen teilweise nicht angezeigt +- große oder hochformatige Produktbilder wurden im Studio Flow abgeschnitten + +## [1.1.0-ui-listings] - 2026-07-07 + +### Geändert + +- Listings-/Historie-Seite vollständig an die freigegebene Vendoo-Designrichtung 2026 angepasst +- neue Filterleiste mit Suche, Plattform, Status und Zeitraum +- benutzerdefinierbare sichtbare Tabellenspalten mit lokaler Speicherung +- dichte, bildorientierte Listing-Tabelle mit SKU, Plattform, AI-Provider, Preis, Status, Lagerort und Publish-Status +- neue Batch-Aktionsleiste für Publish, Nachbearbeitung, Preis, Status, Duplizieren, Etiketten und Löschen +- moderne Seitennavigation und Seitengrößensteuerung +- neue Exportauswahl für CSV und JSON +- verbesserter leerer Zustand und responsive Darstellung +- bestehende CRUD-, Export-, Druck-, Status- und Papierkorb-Funktionen erhalten + +## [1.6.1] - 2026-07-07 + +### Geändert + +- Vendoo Assistant für Chrome, Edge und Firefox vollständig verdichtet und visuell neu strukturiert. +- Queue-Karten zeigen Bild, Status, SKU, Preis und direkte Aktionen ohne große Leerflächen. +- „Nur ausfüllen“ ist jetzt die primäre und sichere Einzelaktion. +- Daten können in Vinted eingetragen werden, ohne automatisch einen Entwurf anzulegen oder zu veröffentlichen. +- „Daten + Bilder ausfüllen“ überträgt Bilder und Felder, führt aber keinen Abschluss aus. +- Entwurf und Veröffentlichung liegen bewusst in einem getrennten, aufklappbaren Abschlussbereich. +- Popup öffnet Vinted mit einem vorgemerkten Listing und füllt es nach dem Laden aus. +- Extension-Version auf 1.4.0 angehoben. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..084fcb4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# Vendoo — Projekt-Kontext + +Du arbeitest am Projekt "Vendoo" — ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +Lies zuerst `FEATURES.md` im Projektroot — das ist die vollstaendige Feature-Dokumentation mit Tech Stack, Projektstruktur, DB-Schema, allen 80+ API-Endpunkten und 17 Feature-Beschreibungen. + +## Tech Stack + +- **Backend:** Node.js + Express (ESM, `"type": "module"`) +- **Frontend:** Vanilla HTML/CSS/JS — kein Framework, kein Build-Step, kein TypeScript +- **Datenbank:** SQLite via `better-sqlite3` (WAL-Modus) +- **AI:** Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) +- **Bilder:** `sharp` (Resize, BG-Remove, Watermark) +- **Rich-Text:** Quill Editor +- **E-Mail:** Nodemailer (SMTP) +- **Extension:** Chrome Manifest v3 + +## Dateistruktur (wo was hingehoert) + +| Aenderung | Datei(en) | +|---|---| +| Neue API-Route | `server.mjs` — Route hinzufuegen + Import erweitern | +| Neue DB-Funktion | `lib/db.mjs` — Funktion exportieren | +| Neue DB-Spalte | `lib/db.mjs` — MIGRATIONS-Array erweitern (try/catch ALTER TABLE Pattern) | +| Neues UI-Tab | `public/index.html` (Sidebar-Button + Section), `public/app.js` (TAB_TITLES + switchToTab + Logik), `public/style.css` | +| Neuer AI-Provider | `lib/ai-*.mjs` + `lib/ai-router.mjs` erweitern | +| Neue Plattform | `platforms/*.mjs` + `lib/categories.mjs` + `lib/fees.mjs` + `lib/html-templates.mjs` | +| Styling | `public/style.css` — CSS Custom Properties fuer Theming | + +## Konventionen + +- **Sprache:** UI ist komplett Deutsch +- **SKU-Format:** `VD-XXXX` (auto-generiert, 4-stellig aufsteigend) +- **Navigation:** Sidebar mit `data-tab` Attributen, Tab-Inhalte als `
                                              ` +- **Topbar-Titel:** `TAB_TITLES` Map in `app.js` muss fuer neue Tabs erweitert werden +- **Dark Mode:** CSS-Variablen unter `[data-theme="dark"]`, niemals hardcoded Farben +- **Auth:** Session-basiert, Rollen `admin`/`editor`, CSRF-Token bei state-changing Requests +- **DB-Migrationen:** Neue Spalten als Eintrag in `MIGRATIONS`-Array (Zeile ~17-21 in db.mjs): + ```js + const MIGRATIONS = [ + ['spaltenname', 'TEXT'], // oder 'REAL', "TEXT DEFAULT 'wert'" + ]; + // Wird automatisch per try/catch ALTER TABLE ausgefuehrt + ``` +- **API-Pattern:** REST, JSON-Body, Fehler als `{ error: 'message' }` +- **Frontend-API-Aufrufe:** `api(method, path, body)` Helper in app.js (fuegt Auth + CSRF automatisch hinzu) +- **Toast-Benachrichtigungen:** `toast('Nachricht')` in app.js +- **Quill-Editor:** `initQuill(selector, placeholder)` — gibt Editor-Instanz zurueck + +## Server starten + +```bash +npm install # Dependencies installieren +npm run dev # Dev-Server mit --watch (Port 8124) +npm start # Produktiv-Server +``` + +Oder: `setup.bat` fuer gefuehrte Installation. + +## Wichtige Dateien nach Groesse + +| Datei | Zeilen | Inhalt | +|---|---|---| +| `public/app.js` | ~3240 | Gesamte Frontend-Logik | +| `server.mjs` | ~1250 | Alle API-Routen + Middleware | +| `public/style.css` | ~1200 | Komplettes Styling + Dark Mode + Responsive | +| `public/index.html` | ~845 | HTML-Struktur (Sidebar, Tabs, Modals, Forms) | +| `lib/db.mjs` | ~390 | Datenbank-Zugriff (alle CRUD-Funktionen) | +| `lib/categories.mjs` | ~360 | Kategorie-Baeume pro Plattform | +| `lib/auth.mjs` | ~290 | Auth-System (Users, Sessions, Audit) | +| `lib/ebay-api.mjs` | ~270 | eBay REST API + OAuth2 | +| `lib/mailer.mjs` | ~210 | SMTP E-Mail-Versand | +| `lib/etsy-api.mjs` | ~185 | Etsy REST API v3 + OAuth2 PKCE | +| `extension/content.js` | ~1170 | Chrome Extension Content Script | + +## Aktueller UI-Stand + +- Version `1.3.0` +- UI 2026 Slice 05: Publish-Center +- Smart Copy, direkte Veröffentlichung, eBay Queue und Scheduling arbeiten in einer gemeinsamen Oberfläche. +- Interne Publish-Arbeitsschritte dürfen keine separaten Browserfenster oder redundanten Modale öffnen. + +## Bildarchitektur ab 1.23.0 +- FLUX/ComfyUI ist der einzige lokale generative Bilddienst. +- Das FLUX Studio arbeitet textbasiert und getrennt von Produktbild-Werkzeugen. +- Keine VTO-, Python-, Torch- oder ONNX-Laufzeit wieder einführen, solange kein neuer ausdrücklicher Architekturentscheid dokumentiert ist. + +- FLUX-Studio-Aufträge und Varianten sind in `flux_prompt_jobs` und `flux_prompt_job_variants` persistent. +- Fortschritt im FLUX Studio wird als reale Phase dargestellt; keine aus Laufzeit geschätzten Prozentwerte ergänzen. +- ComfyUI-Prompt-IDs müssen für Abbruch, Historie und Neustart-Wiederaufnahme erhalten bleiben. + +- Der Advanced Image Editor arbeitet nicht destruktiv: Originaldateien niemals überschreiben oder löschen. +- Jede gespeicherte Bearbeitung muss eine neue Datei und einen Eintrag in `image_edit_versions` erzeugen. +- Bildpfade müssen strikt auf `uploads/` begrenzt und gegen Traversal geschützt bleiben. +- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor nicht als Platzhalter ergänzen; diese Funktionen benötigen eigene spätere Slices. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7687f47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-bookworm-slim AS dependencies +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json .npmrc ./ +RUN npm ci --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production \ + PORT=8124 \ + VENDOO_BIND_HOST=0.0.0.0 \ + VENDOO_DATA_DIR=/app/data \ + VENDOO_DB_PATH=/app/data/db/vendoo.db \ + VENDOO_UPLOADS_DIR=/app/data/uploads \ + VENDOO_BACKUP_DIR=/app/data/backups \ + VENDOO_OPERATIONS_DIR=/app/data/operations \ + VENDOO_LOG_DIR=/app/data/logs \ + VENDOO_CONFIG_PATH=/app/data/config/vendoo.env +WORKDIR /app +COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules +COPY --chown=node:node . . +RUN mkdir -p /app/data/db /app/data/uploads /app/data/backups /app/data/operations /app/data/logs /app/data/config \ + && chown -R node:node /app/data \ + && chmod +x /app/scripts/docker-entrypoint.sh +USER node +EXPOSE 8124 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] +CMD ["node", "bootstrap.mjs"] diff --git a/FEATURES.md b/FEATURES.md index 017e8b0..50b6649 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,101 +1,1007 @@ -# Vendoo – Feature-Dokumentation +## Slice 32 – Batch Image Factory (1.27.0) -> Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. -> Stand: 2026-07-07 | Version 1.0.0 -## Verbindlicher Tech Stack +## Version 1.36.0 – Secure Theme Manager -- Backend: Node.js + Express als ESM -- Frontend: Vanilla HTML/CSS/JavaScript ohne Framework und ohne Build-Schritt -- Datenbank: SQLite über `better-sqlite3` im WAL-Modus -- AI: Anthropic Claude, OpenAI, OpenRouter und Ollama -- Bildverarbeitung: `sharp` -- Rich Text: Quill -- QR-Codes: QRious -- E-Mail: Nodemailer -- Auth: Scrypt, Sessions, Magic Links, Rollen `admin` und `editor`, CSRF -- Browser-Erweiterung: Chrome Manifest v3 +### Persönliche Darstellung -## Verbindliche Projektstruktur +Jeder angemeldete Benutzer kann Theme, UI-Dichte und reduzierte Bewegung über eine eigene Eingabemaske verwalten. Die Präferenz wird serverseitig dem Benutzer zugeordnet und beim nächsten Start erneut geladen. -- `server.mjs`: Express-Server und API-Routen -- `lib/db.mjs`: Datenbankzugriff und `MIGRATIONS`-Array -- `lib/*.mjs`: Auth, AI, Bilder, Gebühren, Kategorien, Mailer und Plattform-APIs -- `platforms/*.mjs`: plattformspezifische Prompt- und Parsing-Logik -- `public/index.html`: Single-Page-Oberfläche -- `public/app.js`: gesamte Frontend-Logik -- `public/style.css`: Styling und Dark Mode -- `extension/`: Chrome-Erweiterung -- `db/schema.sql`: Datenbankschema -- `uploads/`: lokale Produktfotos, nicht versioniert +### Administrative Theme-Profile -## Entwicklungsregeln +Administratoren können validierte Theme-Profile erstellen, bearbeiten, exportieren, importieren und als Systemstandard festlegen. Profile bestehen ausschließlich aus freigegebenen Design Tokens. Freies CSS oder JavaScript wird nicht gespeichert oder ausgeführt. -1. Neue DB-Spalten werden als neue Migration im `MIGRATIONS`-Array von `lib/db.mjs` ergänzt. -2. Migrationen verwenden das bestehende `try/catch ALTER TABLE`-Muster. -3. Neue DB-Funktionen werden aus `lib/db.mjs` exportiert und in `server.mjs` importiert. -4. Neue API-Routen werden in `server.mjs` angelegt. -5. Sidebar-Navigation verwendet `data-tab`. -6. Tab-Inhalte sind `
                                              `. -7. Topbar-Titel werden über die `TAB_TITLES`-Map gesteuert. -8. Dark Mode verwendet ausschließlich CSS Custom Properties unter `[data-theme="dark"]`. -9. Die UI ist deutschsprachig. -10. SKUs werden im Format `VD-XXXX` erzeugt. -11. Schreibende Requests benötigen Session, Rollenprüfung und CSRF-Token. +### Accessibility -## Funktionsbereiche +Der Theme Manager zeigt Kontrastwerte zentraler Farbkombinationen, bietet ein Hochkontrast-Theme, sichtbare Fokuszustände, reduzierte Bewegung und Mindestzielgrößen für Bedienelemente. -1. AI-Listing-Generator -2. Foto-Management und Bildeditor -3. Listing-Verwaltung und Historie -4. Vorlagen-System -5. Publishing-System einschließlich Smart Copy, Etsy, eBay und Warteschlange -6. Lagerverwaltung -7. SKU-System -8. Gebührenrechner -9. Kategorie-System -10. Etiketten- und Datenblattdruck -11. Authentifizierung und Benutzerverwaltung -12. SMTP und E-Mail -13. Systeminformationen, Backup und Restore -14. Chrome-Erweiterung für Vinted -15. Einstellungen und Provider-Konfiguration -16. Responsive UI, Dark Mode, Toasts und Quill -17. Windows-Installer mit Install, Start, Update, Repair, Uninstall und Status +- zentrale Batch-Bildproduktion als eigener Navigationsbereich +- Quellbilder aus FLUX, AI, Artikeln, Editorversionen und früheren Produktionsausgaben +- integrierte Profile für eBay, Vinted, Kleinanzeigen, Etsy, Instagram, Webshop und Originaloptimierung +- eigene Profile mit Größe, Fit-Modus, Hintergrund, Format, Qualität und Dateinamensschema +- mehrere Profile und mehrere Bilder pro Auftrag +- optionale Hintergrundentfernung und Wasserzeichen +- persistente Jobtabellen mit Einzelpositionen, Pause, Fortsetzen, Abbruch, Retry und Neustartwiederaufnahme +- nicht destruktive Versionen und automatische Aufnahme in die zentrale Bildergalerie +- ZIP-Export aller fertigen Ausgaben eines Auftrags -## Datenbereiche +## Stabilitäts- und Diagnosezentrum (1.26.1) -Zentrale Tabellen: +Vendoo überwacht jetzt seine lokale Serververbindung, versieht API-Anfragen mit Diagnose-IDs und zeigt einen klaren Verbindungsstatus statt stiller Folgefehler. Administratoren können unter **Admin → System** eine vollständige, geheimnisfreie Systemprüfung starten und das Ergebnis zusammen mit den letzten Clientfehlern kopieren. Die Oberfläche reagiert zusätzlich auf die reale Breite des Arbeitsbereichs neben der Sidebar und nicht nur auf die Browserbreite. -- `listings` -- `templates` -- `publish_log` -- `ebay_queue` -- `scheduled_publish` -- `settings` -- `users` -- `sessions` -- `auth_tokens` -- `rate_limits` -- `audit_log` -- `login_history` +## Slice 18 – Mobile Mediathek & OpenRouter Diagnostics -## Online- und Docker-Zielbild +- Getrennte Kamera- und Mediathek-Auswahl im QR-Upload +- Mobile Mehrfachauswahl sowie HEIC/HEIF-Konvertierung +- Robuster OpenRouter-Request-Fallback pro Modell +- Dynamische Free-Modell-Diagnose und klarer OpenAI-Fallback-Hinweis -Vendoo soll zusätzlich zur lokalen Windows-Installation später als Docker-Anwendung betrieben werden können. Für öffentliche Nutzer ist ein von privaten NAS-Daten getrennter Server/VPS vorgesehen. Der NAS-Betrieb ist nur für private Nutzung oder einen streng geschlossenen Testkreis vorgesehen. +## Slice 17 – LAN QR, OpenRouter Failover & Mobile Navigation -Zwingende Grundlagen: +- Automatische private LAN-IP-Auswahl mit Adapter-Priorisierung +- Konfigurierbare `PUBLIC_BASE_URL` für QR-Upload +- Mehrstufiger OpenRouter-Failover über aktuelle Free-Bildmodelle +- Chat-Completions- und Images-API-Fallback für OpenRouter +- Robuste mobile Off-Canvas-Navigation -- konfigurierbare Pfade für Datenbank, Uploads und Backups -- `PUBLIC_BASE_URL` -- Reverse-Proxy-Unterstützung und begrenztes `trust proxy` -- HTTPS und sichere Cookies -- Health- und Readiness-Endpunkte -- Secrets-Unterstützung -- nicht privilegierter Container -- konsistente SQLite-Backups -- gehärtete Upload-Verarbeitung -- dynamische OAuth-Callbacks -- spätere Organisations- und Mandantentrennung vor öffentlichem Mehrkundenbetrieb +## Slice 16 – AI Job Queue & Multi-Mode Image Generation -Die ausführliche ursprüngliche Feature-Dokumentation bleibt die fachliche Referenz und soll zusammen mit dem lokalen Quellprojekt vollständig importiert werden. +- Queue-basierte AI-Bildgenerierung mit Status-Polling +- 1–3 Varianten statt nur 2–3 +- Modi: Model / Ghost Mannequin / Flatlay +- AI-Bildhistorie im Listing-Editor + +## Slice 15 – AI Provider Settings Center + +- UI für AI-Model-Foto-Provider und Free-Model-Discovery +- Live-Abfrage aktueller OpenRouter-Bildmodelle aus der Settings-Seite +- Konfigurierbare Safety-Flags und Fallback-Logik ohne Codeänderung + +## Slice 14 – OpenRouter Free Image Discovery + +- Dynamic Lookup gegen OpenRouter Models API (`output_modalities=image`) +- Filter auf kostenlose Bildmodelle, die sich laufend ändern können +- Provider-Automatik: OpenRouter Free zuerst, dann OpenAI-Fallback +- Speicherung von `provider` und `provider_model` für spätere Nachvollziehbarkeit + +## Slice 13 – AI-Model-Fotos mit Safety-Filter + +- Sichert den Workflow für Modeartikel ohne Person im Originalbild ab +- Erzeugt 1–3 zusätzliche Bilder als Model, Ghost Mannequin oder Flatlay +- Blockiert Unterwäsche, Bademode, transparente Erotik-Looks und Kinderkleidung +- Speichert Generierungen und erzeugte Bilder in separaten Tabellen für spätere Nachverfolgung + +# Vendoo - Feature-Dokumentation + +> Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. +> Stand: 2026-07-08 | Version 1.27.0 + +## Design-Freeze 2026 + +Die freigegebenen Referenzen in `MockupDesign/` sind die verbindliche UI-/UX-Richtung. Die Startseite wird als **Studio Flow** umgesetzt und bildet den Artikelzyklus `Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` ab. Gemeinsame Design-Tokens, App-Shell, Navigation, Dark Mode und Kontextpanels werden zentral gepflegt. + +--- + +## Tech Stack + +| Komponente | Technologie | +|---|---| +| Backend | Node.js + Express (ESM) | +| Frontend | Vanilla HTML/CSS/JS (kein Framework, kein Build) | +| Datenbank | SQLite via `better-sqlite3` (WAL-Modus) | +| AI | Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal) | +| Bildverarbeitung | `sharp` (Resize, Hintergrund, Wasserzeichen) | +| Rich-Text | Quill Editor | +| QR-Codes | QRious | +| E-Mail | Nodemailer (SMTP) | +| Auth | Scrypt-Hashing, Session-basiert, Magic Links | +| Extension | Chrome/Edge Manifest v3, Firefox WebExtension, Safari Web Extension | + +--- + +## Projektstruktur + +``` +Vendoo/ +├── server.mjs # Express-Server (1253 Zeilen, alle API-Routen) +├── package.json # Dependencies + Scripts (start, dev) +├── setup.bat / setup.ps1 # Windows-Installer (Install/Update/Repair/Uninstall) +├── .env / .env.example # Konfiguration (API-Keys, SMTP, Port) +├── db/ +│ ├── schema.sql # DB-Schema (listings, templates, settings, publish_log) +│ └── vendoo.db # SQLite-Datenbank +├── lib/ +│ ├── db.mjs # Datenbank-Zugriff (alle CRUD-Funktionen, ~390 Zeilen) +│ ├── auth.mjs # Auth-System (Users, Sessions, Rate-Limiting, Audit, ~290 Zeilen) +│ ├── ai-router.mjs # AI-Provider-Dispatcher +│ ├── ai-claude.mjs # Anthropic Claude Vision API +│ ├── ai-openai.mjs # OpenAI GPT-4o Vision API +│ ├── ai-openrouter.mjs # OpenRouter Multi-Modell API (~110 Zeilen) +│ ├── ai-local.mjs # Ollama lokale Inferenz +│ ├── images.mjs # Bildverarbeitung (Resize, BG-Remove, Watermark) +│ ├── fees.mjs # Gebührenberechnung pro Plattform +│ ├── categories.mjs # Kategorie-Bäume pro Plattform (~360 Zeilen) +│ ├── html-templates.mjs # Plattform-spezifische HTML-Beschreibungs-Wrapper +│ ├── mailer.mjs # SMTP E-Mail-Versand + Templates (~210 Zeilen) +│ ├── etsy-api.mjs # Etsy REST API v3 + OAuth2 PKCE (~185 Zeilen) +│ └── ebay-api.mjs # eBay REST API + OAuth2 (~270 Zeilen) +├── platforms/ +│ ├── vinted.mjs # Vinted Prompt-Template + Parsing +│ ├── ebay-ka.mjs # eBay Kleinanzeigen +│ ├── ebay-de.mjs # eBay.de +│ └── etsy.mjs # Etsy +├── public/ +│ ├── index.html # Haupt-UI (Single Page, ~845 Zeilen) +│ ├── login.html # Login-Seite (Setup, Magic Link, Passwort) +│ ├── style.css # Komplettes Styling inkl. Dark Mode (~1200 Zeilen) +│ └── app.js # Frontend-Logik (~3240 Zeilen) +├── extension/ +│ ├── manifest.json # Chrome Extension Manifest v3 +│ ├── background.js # Service Worker +│ ├── popup.html/js # Extension-Popup +│ ├── content.js # Content Script für Vinted (~1170 Zeilen) +│ ├── content.css # Injiziertes Styling +│ └── vinted-categories.js # Vinted-Kategorie-Mapping +└── uploads/ # Hochgeladene Produktfotos +``` + +--- + +## Datenbank-Schema + +### Tabelle: `listings` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| platform | TEXT NOT NULL | vinted, ebay-ka, ebay-de, etsy | +| ai_provider | TEXT NOT NULL | claude, openai, openrouter, local | +| title | TEXT | Listing-Titel | +| description | TEXT | Beschreibungstext | +| tags | TEXT (JSON) | Array von Tags/Hashtags | +| photos | TEXT (JSON) | Array von Dateinamen | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| brand | TEXT | Marke | +| category | TEXT | Produktkategorie | +| size | TEXT | Groesse | +| color | TEXT | Farbe | +| condition | TEXT | Zustand | +| price | REAL | Preis in EUR | +| status | TEXT DEFAULT 'active' | active, sold, reserved | +| language | TEXT DEFAULT 'de' | Sprache (de,en,fr,nl,pl,es,it,pt) | +| sku | TEXT | Artikelnummer (VD-0001 Format) | +| storage_location | TEXT | Lagerort (z.B. "Regal A2") | +| deleted_at | TEXT | Soft-Delete Timestamp | +| created_at | TEXT | Erstellungsdatum | +| updated_at | TEXT | Letzte Aenderung | + +### Tabelle: `templates` +| Spalte | Typ | Beschreibung | +|---|---|---| +| id | INTEGER PK | Auto-Increment | +| name | TEXT NOT NULL | Vorlagen-Name | +| platform | TEXT | Plattform (null = alle) | +| seller_notes | TEXT | Verkaeuferbemerkungen | +| language | TEXT DEFAULT 'de' | Sprache | +| default_tags | TEXT (JSON) | Standard-Tags | +| created_at | TEXT | Erstellungsdatum | + +### Tabelle: `publish_log` +Tracking aller Veroeffentlichungen (listing_id, platform, status, external_id, external_url, published_at) + +### Tabelle: `ebay_queue` +eBay-Publish-Warteschlange (listing_id, status, ebay_item_id, ebay_url, error, retries) + +### Tabelle: `scheduled_publish` +Zeitgesteuerte Veroeffentlichungen (listing_id, platform, scheduled_at, status, error) + +### Tabelle: `settings` +Key-Value Store (default_platform, default_ai, seller_notes, API-Keys, SMTP-Config) + +### Tabelle: `users` +Benutzerverwaltung (email, name, password_hash, role, active, avatar_color, invited_by) + +### Tabelle: `sessions` +Auth-Sessions (user_id, token_hash, ip, user_agent, expires_at) + +### Tabelle: `auth_tokens` +Magic Links + Einladungen (email, token_hash, type, expires_at, used) + +### Tabelle: `rate_limits`, `audit_log`, `login_history` +Sicherheit und Nachverfolgung + +--- + +## Features im Detail + +### 1. AI-Listing-Generator (Kernfeature) + +**Was:** Fotos hochladen → AI generiert komplettes Listing (Titel, Beschreibung, Tags, Metadaten) + +**Ablauf:** +1. Fotos per Drag & Drop oder Dateiauswahl hochladen (max. 10, je 20MB) +2. Plattform waehlen (Vinted, eBay KA, eBay.de, Etsy) +3. AI-Provider waehlen (Claude, OpenAI, OpenRouter, Ollama) +4. Modell waehlen (bei OpenRouter/Ollama) +5. Sprache waehlen (8 Sprachen) +6. Varianten waehlen (1-3) +7. Optional: Vorlage waehlen (Seller Notes) +8. „Artikel generieren“ klicken +9. AI analysiert Fotos und liefert: Titel, Beschreibung, Tags, Marke, Kategorie, Groesse, Farbe, Zustand, Preisvorschlag +10. Ergebnis in editierbaren Feldern anzeigen (Quill WYSIWYG-Editor) +11. HTML-Vorlage mit plattform-spezifischem Styling generieren +12. Gebuehrenberechnung automatisch anzeigen +13. Speichern erzeugt Listing mit auto-generierter SKU (VD-XXXX) + +**Dateien:** `server.mjs` (POST /api/generate), `lib/ai-*.mjs`, `platforms/*.mjs`, `public/app.js` + +**API:** `POST /api/generate`, `POST /api/upload`, `GET /api/providers`, `GET /api/platforms` + +--- + +### 2. Foto-Management & Editor + +**Upload:** +- Drag & Drop Dropzone +- Multi-File-Auswahl (nur Bilder: jpg, png, webp, gif) +- Vorschau-Thumbnails mit Entfernen-Button +- Drag-to-Sort (Reihenfolge aendern) + +**Bildbearbeitung (Canvas-basiert):** +- Helligkeit (-100 bis +100) +- Kontrast (-100 bis +100) +- Saettigung (-100 bis +100) +- Drehen (links/rechts 90°) +- Spiegeln (horizontal) +- Zuschneiden (Crop-Tool) +- Zuruecksetzen auf Original +- Uebernehmen/Abbrechen + +**Bildtools:** +- Hintergrund entfernen (Corner-Sample-Methode, Toleranz 60) +- Wasserzeichen hinzufuegen (Text-Overlay, 4% Schriftgroesse, 40% Opazitaet) + +**API:** `POST /api/upload`, `POST /api/images/remove-bg`, `POST /api/images/watermark` + +--- + +### 3. Listing-Verwaltung (Historie) + +**Such- und Filteroptionen:** +- Volltextsuche (Titel, Beschreibung, Marke) mit 300ms Debounce +- Plattform-Filter (Dropdown) +- Status-Filter (Aktiv, Verkauft, Reserviert) +- Datumsbereich-Filter (Von/Bis) + +**Listenansicht:** +- Thumbnail + Titel + SKU-Badge +- Plattform + AI-Provider + Preis + Datum +- Lagerort-Anzeige (wenn zugewiesen) +- Publish-Status-Badges pro Plattform (published/copied/pending) +- Inline-Status-Aenderung (Dropdown) +- Duplizieren-Button +- Loeschen-Button (Soft-Delete → Papierkorb) + +**Batch-Operationen:** +- Alle auswaehlen / Einzelauswahl +- Ausgewaehlte loeschen +- Alle loeschen (gefiltert) +- Status massenweise aendern (Aktiv/Verkauft/Reserviert) +- Etiketten drucken (ausgewaehlte) + +**Pagination:** +- Seitengroesse: 10 / 20 / 50 / Alle +- Vor/Zurueck (oben + unten) +- Seite X von Y, Gesamtanzahl + +**Export:** +- CSV-Export (mit Plattform/Status-Filter) +- JSON-Export (alle gefilterten Listings) + +**Detail-Ansicht:** +- vollständiger Listing-Arbeitsbereich statt reduziertem Detailformular +- Fotogalerie mit großem Cover, Thumbnail-Leiste und vollständiger Bilddarstellung +- Fotos hinzufügen, entfernen, per Drag & Drop sortieren und als Cover festlegen +- vorhandene Bilder direkt mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- QR-Code (SKU + ID, via QRious) +- Alle Felder editierbar: Plattform, AI-Provider, Sprache, SKU, Status, Titel, Tags, Verkäufernotizen, Marke, Kategorie, Groesse, Farbe, Zustand, Preis, Lagerort +- Beschreibung gleichzeitig im Quill-WYSIWYG und im HTML-Code bearbeiten +- WYSIWYG- und HTML-Codeansicht sind live und bidirektional synchronisiert +- separate sandboxed Plattformvorschau +- Editor-HTML und vollständiges Plattform-HTML separat kopierbar +- Aenderungen speichern +- Alles kopieren +- Etikett erstellen +- Datenblatt erstellen +- Loeschen + +**API:** `GET/POST/PUT/DELETE /api/listings`, `POST /api/listings/:id/duplicate`, `GET /api/export/csv` + +--- + +### 4. Vorlagen-System (Templates) + +**3 vordefinierte Vorlagen (Seed):** +1. "Sneaker & Schuhe" (Vinted, DE) — Versand-/Zustandshinweise + Sneaker-Tags +2. "Vintage Kleidung" (Vinted, DE) — Vintage-spezifische Hinweise + Retro-Tags +3. "Elektronik & Zubehoer" (eBay.de, DE) — Funktionshinweise + Technik-Tags + +**Verwaltung:** +- Liste aller Vorlagen (Name, Plattform, Sprache) +- Erstellen: Name, Plattform (optional), Sprache, Seller Notes (Quill), Standard-Tags +- Bearbeiten / Loeschen +- Vorlagen-Auswahl im Generator (Dropdown) +- Bei Auswahl: uebernimmt Plattform + Sprache + Seller Notes + Tags + +**API:** `GET/POST/PUT/DELETE /api/templates` + +--- + +### 5. Publishing-System + +**UI 2026 / Slice 05 – Publish-Center:** +- gemeinsame Arbeitsoberfläche für Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung +- bildorientierte Listing-Auswahl mit Suche, Filtern, Sortierung und Mehrfachauswahl +- inline bearbeitbare Publish-Daten mit Pflichtfeld- und Integrationsprüfung +- Queue-Status, Fehlerwiederholung, Aktivitätsverlauf und Schnellaktionen in einer Statusleiste +- keine separaten Browserfenster für interne Publish-Arbeitsschritte + +**Smart Copy (manuelles Veroeffentlichen):** +- Schritt-fuer-Schritt-Anleitung pro Plattform +- Fotogalerie mit Download +- Kopierbare Felder: Titel, Beschreibung (Text + HTML), Tags, Preis, Metadaten +- "Plattform oeffnen" Button (Direkt-URL) +- "Als veroeffentlicht markieren" Button + +**Direkte API-Veroeffentlichung:** +- Etsy: OAuth2 + PKCE → Listing erstellen + Bilder hochladen +- eBay.de: OAuth2 → Inventory Item + Offer + Publish + +**eBay-Warteschlange:** +- Listings zur Queue hinzufuegen (Batch) +- Queue-Ansicht: SKU, Titel, Status, Fehler +- Alle verarbeiten / Fehlgeschlagene wiederholen / Queue leeren +- Status-Tracking: pending → uploading → published/failed + +**Zeitgesteuerte Veroeffentlichung:** +- Datum + Uhrzeit waehlen +- Plattform waehlen +- Batch-Scheduling moeglich +- Uebersicht aller geplanten Veroeffentlichungen +- Background-Job prüft alle 60 Sekunden auf faellige Items + +**Publish-Status-Tracking:** +- Publish-Log pro Listing + Plattform +- Status-Badges in Historie + Publish-Ansicht + +**API:** `POST /api/publish/smart-copy`, `POST /api/publish/mark`, `POST /api/publish/etsy`, `POST /api/ebay/publish/:id`, `GET/POST/DELETE /api/ebay/queue`, `GET/POST/DELETE /api/schedule` + +--- + +### 6. Lagerverwaltung (Inventory) + +**Uebersicht:** +- Statistik-Karten: Artikel gesamt, Lagerorte, Aktive Artikel, Lagerwert (EUR) +- Lagerort-Kacheln mit: Name, Artikelanzahl (aktiv/verkauft/reserviert), Gesamtwert +- "Ohne Lagerort" Kachel fuer nicht zugewiesene Artikel +- Neuen Lagerort anlegen + +**Detail-Ansicht (pro Lagerort):** +- Artikelliste mit Checkboxen +- SKU, Titel, Plattform, Preis, Status +- Lagerort umbenennen +- Ausgewaehlte Artikel an anderen Ort verschieben (Bulk) + +**Integration:** +- Lagerort-Feld in Listing-Detail editierbar +- Lagerort-Feld im Generator (bei Erstellung setzbar) +- Lagerort in Historie-Listenansicht sichtbar + +**API:** `GET /api/inventory/locations`, `GET /api/inventory/unassigned`, `GET /api/inventory/location/:loc`, `PUT /api/inventory/bulk-location` + +--- + +### 7. SKU / Artikelnummer-System + +- Auto-Generierung: VD-0001, VD-0002, ... (4-stellig, aufsteigend) +- Backfill fuer bestehende Listings ohne SKU +- SKU in Detail-Ansicht editierbar +- SKU in QR-Codes kodiert (JSON: {sku, id}) +- SKU auf Etiketten und Datenblaettern + +--- + +### 8. Gebuehrenrechner + +**Plattform-Gebuehren:** +| Plattform | Provision | Fixkosten | +|---|---|---| +| Vinted | 5% Kaeuferschutz | + 0,70 EUR | +| eBay Kleinanzeigen | 0% | Kostenlos | +| eBay.de | 11% Verkaufsprovision | + 0,35 EUR | +| Etsy | 6,5% + 4% Payment | + 0,30 + 0,20 EUR | + +- Echtzeit-Berechnung bei Preiseingabe +- Zeigt: Brutto, Gebuehren, Netto pro Plattform +- Auch im Generator bei Preisvorschlag + +**API:** `GET /api/fees` + +--- + +### 9. Kategorie-System + +- Hierarchischer Kategorie-Picker (Baum mit Gruppen/Untergruppen) +- Suchfunktion mit Scoring (Name + Pfad) +- Plattform-spezifische Kategorien (Vinted, eBay KA, eBay.de, Etsy) +- Caching fuer Performance +- Ausgewaehlte Kategorie mit Clear-Button + +**API:** `GET /api/categories/:platform`, `GET /api/categories/:platform/match` + +--- + +### 10. Druck-Funktionen + +**Etiketten (Labels):** +- 2-Spalten-Grid, A4-druckfertig +- QR-Code + SKU + Titel (40 Zeichen) + Plattform + Preis +- Batch-Druck fuer mehrere Listings + +**Datenblaetter:** +- Volle Listing-Details auf A4 +- QR-Code + bis zu 4 Fotos +- Beschreibung (200 Zeichen) + Metadaten-Tabelle + +--- + +### 11. Auth & Benutzerverwaltung + +**Authentifizierung:** +- Passwort-Login (Scrypt-Hashing) +- Magic Links per E-Mail (15 Min gueltig) +- Setup-Modus (erster Admin erstellt Konto) +- CSRF-Token-Validierung +- Rate-Limiting (5 Versuche / 15 Min) +- Session-basiert (3 Tage Ablauf, automatische Bereinigung) + +**Benutzerverwaltung (Admin):** +- Benutzer einladen (Magic Link oder Auto-Passwort) +- Rollen: Admin, Editor +- Benutzer aktivieren/deaktivieren +- Passwort zuruecksetzen +- Login-Verlauf einsehen +- Sessions verwalten (eigene + alle) +- Einladung erneut senden + +**Audit-Log:** +- Alle Aktionen protokolliert (Login, CRUD, Settings, etc.) +- Benutzer, Aktion, Ziel, IP, Timestamp +- Paginierte Ansicht + +**API:** `POST /auth/login`, `POST /auth/setup`, `POST /auth/invite`, `GET /api/me`, Admin-Routen unter `/api/admin/*` + +--- + +### 12. E-Mail / SMTP + +- Konfigurierbar ueber Admin-Panel oder .env +- SMTP-Verbindungstest +- Magic-Link-Versand +- Einladungs-E-Mails mit Passwort +- Gebrandetes HTML-Template (Vendoo-Header) +- Fallback: Console-Output wenn SMTP nicht konfiguriert + +**API:** `GET/PUT /api/admin/smtp`, `POST /api/admin/smtp/test` + +--- + +### 13. System & Backup (Admin) + +**System-Info:** +- Node.js Version, Uptime, RAM-Verbrauch +- Datenbank-Groesse +- Benutzer- und Listing-Anzahl +- Aktive Sessions + +**Backup & Restore:** +- DB-Download als .db-Datei +- DB-Restore per Upload (erstellt Backup der alten DB) +- Abgelaufene Sessions bereinigen + +**API:** `GET /api/admin/system`, `GET /api/backup`, `POST /api/restore` + +--- + +### 14. Chrome Extension + +**Zweck:** Automatisches Ausfuellen von Vinted-Listing-Formularen + +**Features:** +- Verbindung zum Vendoo-Server (localhost:8124) +- Queue-Verwaltung: naechstes Listing aus Warteschlange +- Auto-Fill: Titel, Beschreibung, Fotos, Kategorie, Metadaten +- Vinted-Kategorie-Mapping (337 Zeilen) +- Status-Badge (Connected/Offline) +- Batch-Verarbeitung mit konfigurierbarem Delay (Standard 45s) + +**Host Permissions:** localhost:8124, vinted.de/.fr/.co.uk/.com + +--- + +### 15. Einstellungen + +- API-Keys: Anthropic, OpenAI, OpenRouter (maskiert angezeigt) +- Ollama-URL (Standard: localhost:11434) +- Etsy API Key + OAuth-Verbindung +- eBay Client ID/Secret/RuName + OAuth + Sandbox-Toggle +- Standard-Plattform, Standard-AI, Standard-Sprache +- Verkaeuferbemerkungen (global, in jedes Listing eingefuegt) + +**API:** `GET/PUT /api/settings` + +--- + +### 16. UI / Design + +- **Sidebar-Navigation** (links, 230px, fixed) mit 4 Gruppen: Allgemein, Listings, Tools, System +- **Topbar** mit dynamischem Titel, Suchen-Button und „Neuer Artikel“-Button +- **Dark Mode** (Toggle im Sidebar-Footer, localStorage-Persistenz, System-Praeferenz) +- **Responsive** (Mobile: Sidebar versteckt, Hamburger-Menue, Overlay) +- **Toast-Benachrichtigungen** (2,5s) +- **Browser-Notifications** (Listing gespeichert, eBay Queue, Scheduled) +- **Quill WYSIWYG-Editor** (Toolbar: H1-H3, Bold, Italic, Underline, Strike, Listen, Links, Farben) +- **Live-HTML-Workbench**: WYSIWYG, editierbarer HTML-Code und Plattformvorschau gleichzeitig sichtbar + +--- + +### 17. Installer (setup.bat + setup.ps1) + +**Menue-Optionen:** +1. Installieren (Node.js pruefen/installieren, npm install, .env erstellen, Ordner) +2. Starten (Server + Browser oeffnen) +3. Update (Dependencies aktualisieren, neue .env-Eintraege) +4. Reparieren (node_modules loeschen + neu installieren) +5. Deinstallieren (node_modules + optional Daten entfernen) +6. Status (Node/npm/Dependencies/.env/DB/Uploads pruefen) + +**CLI-Modus:** `setup.bat install`, `setup.bat update`, etc. + +--- + +## API-Endpunkt-Uebersicht + +### Auth +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /auth/setup | Erstes Admin-Konto erstellen | +| POST | /auth/login | Anmelden | +| POST | /auth/logout | Abmelden | +| POST | /auth/invite | Einladung senden | +| POST | /auth/accept-invite | Einladung annehmen | +| GET | /auth/magic | Magic-Link-Redirect | +| GET | /auth/setup-check | Setup-Modus pruefen | +| GET | /auth/etsy | Etsy OAuth starten | +| GET | /auth/etsy/callback | Etsy OAuth Callback | +| GET | /auth/ebay | eBay OAuth starten | +| GET | /auth/ebay/callback | eBay OAuth Callback | + +### Listings +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/listings | Alle Listings (q, platform, status) | +| GET | /api/listings/:id | Einzelnes Listing | +| POST | /api/listings | Listing erstellen | +| PUT | /api/listings/:id | Listing aktualisieren | +| DELETE | /api/listings/:id | Listing loeschen (Soft-Delete) | +| POST | /api/listings/:id/duplicate | Listing duplizieren | + +### Generator & AI +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/generate | AI-Listing generieren | +| POST | /api/upload | Fotos hochladen | +| GET | /api/providers | AI-Provider + Modelle | +| GET | /api/platforms | Marktplatz-Plattformen | +| GET | /api/categories/:platform | Kategorie-Baum | +| GET | /api/categories/:platform/match | Kategorie-Suche | +| GET | /api/price-suggest | Preisvorschlag | + +### Bilder +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/images/remove-bg | Hintergrund entfernen | +| POST | /api/images/watermark | Wasserzeichen hinzufuegen | + +### Templates +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/templates | Alle Vorlagen | +| POST | /api/templates | Vorlage erstellen | +| PUT | /api/templates/:id | Vorlage aktualisieren | +| DELETE | /api/templates/:id | Vorlage loeschen | + +### Publishing +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/publish/info/:id | Publish-Status + Verfuegbarkeit | +| POST | /api/publish/smart-copy | Smart-Copy-Daten | +| POST | /api/publish/mark | Als veroeffentlicht markieren | +| GET | /api/publish/status | Batch-Status-Abfrage | +| POST | /api/publish/etsy | Direkt auf Etsy veroeffentlichen | +| POST | /api/ebay/publish/:id | Direkt auf eBay veroeffentlichen | + +### eBay Queue +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/ebay/queue | Warteschlange anzeigen | +| POST | /api/ebay/queue | Listings hinzufuegen | +| DELETE | /api/ebay/queue/:id | Aus Queue entfernen | +| DELETE | /api/ebay/queue | Queue leeren | +| POST | /api/ebay/queue/process | Alle verarbeiten | +| POST | /api/ebay/queue/retry | Fehlgeschlagene wiederholen | +| GET | /api/ebay/policies | eBay-Richtlinien abrufen | + +### Scheduling +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/schedule | Geplante Veroeffentlichungen | +| POST | /api/schedule | Veroeffentlichung planen | +| DELETE | /api/schedule/:id | Geplante entfernen | + +### Inventory +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/inventory/locations | Lagerorte mit Statistiken | +| GET | /api/inventory/unassigned | Artikel ohne Lagerort | +| GET | /api/inventory/location/:loc | Artikel pro Lagerort | +| PUT | /api/inventory/bulk-location | Bulk-Lagerort-Zuweisung | + +### Papierkorb +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/trash | Geloeschte Listings | +| GET | /api/trash/count | Anzahl im Papierkorb | +| POST | /api/trash/:id/restore | Wiederherstellen | +| DELETE | /api/trash/:id | Endgueltig loeschen | +| DELETE | /api/trash | Papierkorb leeren | + +### Gebuehren & Export +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/fees | Gebuehren berechnen | +| GET | /api/export/csv | CSV-Export | +| GET | /api/dashboard/stats | Dashboard-Statistiken | +| GET | /api/html-templates | HTML-Beschreibungs-Templates | +| POST | /api/html-wrap | Gespeichertes Listing in Plattform-HTML wrappen | +| POST | /api/html-render | Nicht persistierende Live-HTML-Vorschau rendern | + +### Mobiler Foto-Upload +| Methode | Route | Beschreibung | +|---|---|---| +| POST | /api/mobile-upload-sessions | Zeitlich begrenzte Upload-Sitzung und QR-Link erzeugen | +| GET | /api/mobile-upload-sessions/:id | Upload-Sitzung vom Desktop pollen | +| DELETE | /api/mobile-upload-sessions/:id | Upload-Sitzung schließen | +| GET | /mobile-upload/:token | Responsive Smartphone-Uploadseite | +| GET | /mobile-upload-api/:token | Öffentlichen Sitzungsstatus abrufen | +| POST | /mobile-upload-api/:token/photos | Fotos mit Bearer-Token aus dem QR-Link hochladen | + +### Einstellungen & System +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/settings | Alle Einstellungen | +| PUT | /api/settings | Einstellungen speichern | +| GET | /api/me | Aktueller Benutzer | +| PUT | /api/me | Profil aktualisieren | +| GET | /api/backup | DB-Backup herunterladen | +| POST | /api/restore | DB wiederherstellen | +| GET | /api/etsy/status | Etsy-Verbindungsstatus | +| GET | /api/ebay/status | eBay-Verbindungsstatus | + +### Admin (erfordert Admin-Rolle) +| Methode | Route | Beschreibung | +|---|---|---| +| GET | /api/admin/users | Alle Benutzer | +| POST | /api/admin/users/invite | Benutzer einladen | +| PUT | /api/admin/users/:id | Benutzer bearbeiten | +| DELETE | /api/admin/users/:id | Benutzer loeschen | +| GET | /api/admin/sessions | Alle Sessions | +| DELETE | /api/admin/sessions/:id | Session beenden | +| GET | /api/admin/audit | Audit-Log | +| GET | /api/admin/system | System-Info | +| GET/PUT | /api/admin/smtp | SMTP-Konfiguration | +| POST | /api/admin/smtp/test | SMTP testen | + +## Slice 07 – Cross-Browser Extensions & Mobile Upload + +- `extensions/chrome`, `extensions/edge` und `extensions/firefox` +- Vendoo Link Icon-Set in vier Browsergrößen +- neues Popup und neues In-Page-Panel im freigegebenen Vendoo-Design +- Plattformvorschau/HTML als Umschalter im Generator und Listing-Editor +- responsive Layouts für Desktop, Notebook, iPad und Smartphone +- QR-Code-Upload vom Smartphone mit 20 Minuten gültiger Upload-Sitzung +- Handy-Fotos erscheinen automatisch im Generator beziehungsweise geöffneten Listing + +## Extension UX 1.4.0 + +- Kompakte Queue-Karten mit Produktbild, Preis, SKU und Status +- Direkte Einzelaktion „Nur ausfüllen“ ohne Entwurf und ohne Veröffentlichung +- Option „Daten + Bilder ausfüllen“ ohne Abschlussaktion +- Explizit getrennte Aktionen für Entwurf und Veröffentlichung +- Gleichstand der Chrome-, Edge- und Firefox-Unterordner + + +## Interaktives Dashboard und Studio Flow V2 + +- eigenständiger Dashboard-Menüpunkt mit realen Kennzahlen und interaktiven SVG/CSS-Diagrammen +- Studio Flow verwendet die verständlichen Zustände Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit +- mehrere Fotodateien werden automatisch als ZIP heruntergeladen +- Publish-Center verfügt über Retry- und Stale-Render-Schutz beim ersten Laden + + +## Local FLUX / ComfyUI Integration (Slice 19) +- New local image provider option for AI model photos +- Auto provider order: Local FLUX / ComfyUI -> OpenRouter Free -> OpenAI +- Local status route, bootstrap install route and token regeneration route +- Setup menu extended with local FLUX bootstrap + status +- Security concept: backend-to-backend access, optional token, localhost by default + + +## Slice 20 – Local FLUX Full Installer + +- Vollautomatischer ComfyUI-Portable-Installer für Windows +- Dynamische Auswahl des aktuellen offiziellen Release-Assets +- GPU-Erkennung und passende Portable-Variante +- Optionaler FLUX.1-schnell-FP8-Modell-Download +- Fortschrittsanzeige in der Vendoo-UI +- Update-, Start-, Stop- und Statusaktionen +- Autostart über geplante Windows-Aufgabe +- Localhost-only Sicherheitsmodus + + +## Slice 21 – FLUX Install Diagnostics +- Detailed install readiness dashboard +- Real BITS download progress for ComfyUI and FLUX +- Resume-safe `.part` downloads +- 7-Zip path and PowerShell 5.1 compatibility fix +- Install log preview and error source details +- Setup CLI status with component checks + + +## Slice 22 – Reliable extraction status +- Supervised 7-Zip extraction with timeout and activity checks +- Live UI telemetry: progress, elapsed seconds, file count, extracted MB and heartbeat +- Separate 7-Zip output/error log display +- Automatic stale-state detection and safe resume + +## Slice 23 – FLUX Product Reference, Live Progress & Settings Command Center + +- Product photos are uploaded to local ComfyUI and used as a real image-to-image reference. +- Reference fidelity can be configured from 30–90 percent. +- Local FLUX job progress updates across preparation, upload, rendering, moderation and persistence. +- Settings are grouped into General, AI & APIs, Marketplaces and Local FLUX. +- Local FLUX has explicit enabled/disabled/running/stopped/not-installed states. +- Admin actions: install/repair, update, start, stop, status check and safe uninstall. +- Real installation files override stale or unreadable installer-state metadata. +- Uninstall keeps download archives by default and never removes listings, uploads or database data. + +## Slice 28 – Eigenständiges FLUX Studio und VTO-Bereinigung + +### FLUX Studio +- eigener Hauptmenüpunkt und eigene Seite +- freier Textprompt ohne automatische Produktreferenz +- Stilvorlagen, quadratische/Hochformat-/Querformat-Ausgabe +- Seed und Inferenzschritte steuerbar +- Live-Fortschritt aus ComfyUI +- Vorschau, Download und persistente Bildhistorie +- Übernahme eines erzeugten Bildes in den Listing-Generator +- nachträgliche Bearbeitung: Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung +- Bearbeitungen werden als neue Bildversion gespeichert; Original bleibt erhalten + +### Bereinigte Architektur +- keine FASHN-/VTO-Engine, kein Python-/Torch-/ONNX-Stack und kein VTO-Dienst mehr +- keine VTO-Menüs, API-Routen, Datenbankmodelle oder aktiven Installationsoptionen +- alte VTO-Runtime und Autostarts werden beim Upgrade einmalig entfernt +- FLUX/ComfyUI, Listings, Datenbank, Uploads und Konfiguration bleiben erhalten + +## Slice 30 – FLUX Studio Pro + +- persistente SQLite-Aufträge und einzelne Varianten +- 1, 2 oder 4 Varianten pro Auftrag +- eindeutige Seeds, Seed Lock und exakte Seed-Wiederholung +- „Ähnlich erneut erzeugen“ mit übernommenem Prompt-/Parameterstand und neuem Seed +- Favoriten und Favoritenfilter +- Job-Center für wartende, laufende, teilweise fertige, fehlgeschlagene und abgebrochene Aufträge +- Abbruch und Wiederholung +- ComfyUI-Queue, -History und -Systemdaten einschließlich GPU/VRAM +- Wiederaufnahme über persistierte ComfyUI-Prompt-IDs nach Vendoo-Neustart +- phasenbasierter Fortschritt ohne geschätzte Prozentwerte +- reiner Text-Prompt-Workflow ohne Produktreferenz oder VTO + + +## Slice 31 – Advanced Image Editor + +- nicht destruktiver Editor für Generator-, Listing- und FLUX-Bilder +- Undo/Redo und lokale Bearbeitungshistorie +- persistente Versionenkette über `image_edit_versions` +- Original jederzeit wiederherstellbar +- Vorher-/Nachher-Ansicht +- freier Zuschnitt und feste Seitenverhältnisse 1:1, 4:5, 3:4, 16:9 und Original +- Drehen, horizontales/vertikales Spiegeln und Begradigen +- Belichtung, Helligkeit, Kontrast, Lichter, Schatten, Temperatur, Sättigung und Schärfe +- Export als JPEG, WebP oder PNG mit Qualität und Zielgröße +- serverseitiges Rendering in voller Auflösung mit `sharp` +- jede Speicherung erzeugt eine neue Datei; vorhandene Bilder werden nicht überschrieben +- Hintergrundentfernung und Wasserzeichen sind als nicht destruktive Editor-Werkzeuge integriert +- keine Platzhalter für Inpainting, Outpainting oder Maskeneditor + + + +## Hotfix 1.23.2 – FLUX-Layout und kompakte Aufträge + +- System- und ComfyUI-Karte steht unterhalb von Prompt & Auftrag +- Vorschau bleibt separat, damit Bildaktionen beim Scrollen nicht verdeckt werden +- komplettes Job-Center ein-/ausklappbar +- einzelne Aufträge kompakt ein-/ausklappbar +- aktive Aufträge standardmäßig geöffnet, abgeschlossene standardmäßig kompakt +- Wasserzeichen mit Text, Position und Deckkraft im Advanced Image Editor +- Hintergrundentfernung im Advanced Image Editor mit transparenter PNG-Ausgabe + + +## Version 1.24.0 – Responsive UI, FLUX-Archiv und Bildergalerie + +### Responsive Oberfläche + +- Die Listing-Tabelle passt sich auf Desktop, Tablet und Smartphone an. +- Unterhalb von 860 Pixeln werden Listings als kompakte Karten mit klar beschrifteten Datenzeilen dargestellt. +- Filter, Batch-Aktionen, Pagination, Detailansichten und Aktionsleisten umbrechen ohne die Seitenbreite zu sprengen. +- Galerie, FLUX-Job-Center, Einstellungen, Generator- und Publish-Bereiche erhalten zusätzliche Breiten- und Overflow-Sicherungen. + +### Hintergrundentfernung mit Intensität + +- Die Hintergrundentfernung im Advanced Image Editor besitzt einen Regler von 0 bis 100. +- Niedrige Werte schützen feine Kanten und ähnliche Produktfarben. +- Hohe Werte entfernen einen größeren Farbbereich des erkannten Hintergrunds. +- Vorschau und serverseitiger Export verwenden dieselbe Intensitätslogik. + +### FLUX-Auftragsarchiv und Pagination + +- Abgeschlossene, fehlgeschlagene und abgebrochene Aufträge können einzeln oder gesammelt archiviert werden. +- Laufende, wartende und abbrechende Aufträge sind gegen Archivierung geschützt. +- Archivierte Aufträge können einzeln oder gesammelt wiederhergestellt werden. +- Im Archiv lassen sich einzelne, mehrere oder alle Aufträge endgültig löschen. +- Das Löschen von Auftragsdaten löscht keine bereits erzeugten Bilder. +- Aktive Liste und Archiv verwenden serverseitige Pagination mit 10, 20 oder 50 Aufträgen pro Seite. + +### Zentrale Bildergalerie + +- Eigener Navigationspunkt „Bilder“. +- Gemeinsame Bibliothek für FLUX-Bilder, bearbeitete Versionen, AI-Bilder und Bilder aktiver Listings. +- Raster- und Listenansicht. +- Suche nach Prompt, Titel und Dateiname. +- Filter nach Quelle und Favoriten sowie Sortierung nach Datum oder Name. +- Pagination mit 24, 48 oder 96 Bildern pro Seite. +- Mehrfachauswahl, ZIP-Download und kontrollierte Mehrfachlöschung. +- Große Lightbox mit Metadaten, Prompt, Download, Favorit, Bearbeiten und Löschen. +- Direkte Übergabe eines Galeriebildes an den Generator oder Advanced Image Editor. +- Bilder, die in aktiven Listings verwendet werden, sind vor physischer Löschung geschützt. + + + +## Version 1.26.0 – Artikel-Navigation, Publish, Zoom & Extensions + +- „Neuer Artikel“ als eigener Menüpunkt unmittelbar unter Dashboard; Hauptbereich „Artikel“ statt „Listings“. +- Generierungsaktion heißt konsistent „Artikel generieren“. +- Publish-Workspace lädt auch bei älteren oder beschädigten JSON-Feldern robust; direkter Publish-Scope korrigiert. +- Smart Copy wird als zentriertes Popup geöffnet, während die mittlere Arbeitsfläche die vollständigen Verkaufsdaten zeigt. +- Galerie-Lightbox mit stufenlosem Zoom, Fit, 100 %, Mausradsteuerung und Verschieben. +- Kleine lokale SVG-Icons für Auswahl, Favorit und Medienaktionen. +- Gebührenmodell auf private Verkäufer ausgerichtet; Etsy als Shop-Sonderfall transparent ausgewiesen. +- Eigenes Extension Center mit Browsererkennung und Vorbereitung für Chrome, Edge, Firefox und Safari. +- Safari-Quelle enthält Konvertierungsskript für Xcode/macOS. + +## Version 1.25.1 – Adaptive Bildergalerie + +- Vollständig fließendes Galerie-Raster ohne starre Zwei-/Einspalten-Breakpoints +- Bildfläche selbst öffnet die Vorschau; kein separater Öffnen-Button +- Kartenbreiten, Overlay-Steuerungen und Aktionsdock passen sich per Container Query an +- Direkte Seitenauswahl oberhalb und unterhalb der Galerie +- Kompakte Symbolaktionen auf sehr schmalen Karten und vollständige Beschriftung bei ausreichender Breite +- Saubere Tastaturbedienung für Bildöffnung, Auswahl, Favorit und Pagination + +## Version 1.25.0 – Detail-Responsive, Gallery UX, Smart Copy & Prompt Lab + +- Inhaltsbreitenabhängiger Listing-Detail-Editor mit Container Queries, flexiblen Formularrastern und umbrechender WYSIWYG-Toolbar. +- Überarbeitete Galerie-Karten mit eigener Auswahlsteuerung, Favoriten-Overlay, klarer Vorschau und stabiler Aktionsmatrix. +- Smart Copy als responsives Modal statt verlorenem Inline-Workflow. +- Ruhiger Publish-Refresh ohne rotierenden Gesamtbutton. +- FLUX-Bildbibliothek mit serverseitiger Pagination (5/10/15). +- FLUX Job-Center mit begrenzten Seitengrößen 5/10/15. +- Prompt Lab für lokale Strukturierung und AI-gestützte Prompt-Veredelung über den aktiven Text-AI-Provider. +## Version 1.28.0 – Artikel Quality Center + +- Zentrale Qualitätsprüfung aller Artikel mit Score 0–100 und verständlichen Noten A–E. +- Harte Publishing-Blocker getrennt von Warnungen und freiwilligen Empfehlungen. +- Prüfung von Inhalt, Metadaten, Bildern, Preis, Organisation, Duplikaten und Vendoo-Plattformprofilen. +- Bildmetadatenanalyse und persistenter Qualitätsverlauf. +- Gebühren- und Erlösübersicht direkt im Qualitätsbericht. +- AI-Vorschläge für Titel, Beschreibung und Tags mit verpflichtender Nutzerbestätigung. +- Suche, Status-/Notenfilter und Pagination. + + + +## Version 1.29.0 – Publishing Hardening + +Vendoo bündelt direkte API-Veröffentlichungen und Extension-Workflows in einer persistenten Publishing-Queue. Aktive Doppelaufträge werden zusammengeführt; bereits live protokollierte Artikel benötigen eine ausdrückliche erneute Freigabe. Temporäre API-Fehler werden kontrolliert wiederholt. Alle Schritte erzeugen Audit-Ereignisse. eBay-Angebote können anhand der gespeicherten Offer-ID synchronisiert, aktualisiert und beendet werden. Etsy unterstützt Veröffentlichung und Statusabgleich. Vinted und Kleinanzeigen bleiben sichere, vom Nutzer bestätigte Browser-Extension-Abläufe. + + +## Version 1.30.0 – Operations Center + +- Betriebszentrale unter **Admin → System** mit getrennten Bereichen für Backup & Restore, Update Center und Extension-Diagnose. +- Konsistente SQLite-Snapshots über die Backup-API statt direkter Kopie der geöffneten Datenbank. +- Manuelle Voll- oder Datenbankbackups mit Uploads, optionaler `.env`, Manifest und SHA-256-Prüfsummen. +- Sofortige Prüfung jedes Backups einschließlich SQLite-`integrity_check`. +- Rotierende tägliche automatische Backups mit Uhrzeit, Aufbewahrung und Upload-Option. +- Verifizierte Wiederherstellung mit automatisch vorgeschaltetem Sicherheitsbackup. +- Update-Staging prüft neue Version, Pflichtdateien und verbotene Laufzeitdaten. +- Updates und Wiederherstellungen werden ausschließlich bei beendetem Server über Setup-Menüpunkt 11 angewendet. +- Setup-Menüpunkt 12 stellt den vorherigen Programm- und Laufzeitstand aus einem lokalen Rollback-Snapshot wieder her. +- Sichtbarer Migrationsstatus, letzte Operation und ausstehende Operation. +- Optionale Online-Updateprüfung über eine selbst konfigurierte öffentliche Manifest-URL. +- Vendoo Link 1.9.0 sendet Diagnose-Heartbeats; Chrome, Edge, Firefox und Safari werden getrennt geprüft. +- Automatische Backups enthalten niemals `.env`; Update-Pakete mit `.env`, Datenbank, Uploads oder `node_modules` werden blockiert. +## Version 1.31.0 – Mehrbenutzer, Rollen und Server-Sicherheit + +### Benutzer und Rollen +- Rollenmatrix mit Administrator, Manager, Editor, Publisher und Leser. +- Serverseitige Berechtigungsprüfung für Artikel, Medien, AI/FLUX, Publishing, Einstellungen, Benutzer, Sitzungen, Audit und Operations Center. +- Schutz davor, den letzten aktiven Administrator zu deaktivieren oder zu löschen. + +### Sitzungen und Login-Schutz +- Sichere, gehashte Session-Tokens mit absoluter Laufzeit und Inaktivitäts-Timeout. +- Widerruf einzelner oder aller Benutzersitzungen. +- Kontosperre nach wiederholten Fehlanmeldungen. +- Starke Passwortregeln für Setup, Einladung und Passwortwechsel. + +### Zusammenarbeit +- Persistente Artikel-Bearbeitungssperren mit Benutzeranzeige, automatischer Verlängerung und Ablauf. +- Leser und gesperrte Benutzer sehen Artikel schreibgeschützt. +- `created_by` und `updated_by` werden bei neuen Änderungen gespeichert. + +### Server-Härtung +- Standardbindung an localhost. +- Konfigurierbare Host- und Origin-Freigaben. +- Rate Limits für lesende und verändernde API-Aufrufe. +- Helmet/CSP, Request-IDs und restriktive CORS-Antworten. +- Filterbares Audit-Protokoll mit CSV-Export. + + + +## Version 1.33.0 – Docker, NAS und Produktionsbereitstellung + +- Dockerfile und Compose für reproduzierbaren Betrieb +- nicht privilegierter, read-only Hauptcontainer +- getrennte persistente Datenbereiche +- Liveness, Readiness und geordneter Shutdown +- Reverse-Proxy-, Cookie-, Origin- und Erst-Setup-Härtung +- sichere Docker-/NAS-Backup-, Restore- und Updateabläufe +- Windows-Lokalbetrieb bleibt vollständig erhalten +- FLUX/ComfyUI bleibt ein optionaler externer Dienst + +## 1.33.0 – Multiplatform Deployment & UX Fixes + +- Windows-Zielauswahl: lokal, Docker Desktop, NAS/Portainer, VPS/Linux +- Linux-/NAS-/VPS-Assistent `setup.sh` +- neutraler FLUX-Studio-Start und Arbeitsbereich-Reset +- stabilisierte Benutzerbearbeitung und sichere Update-Antwort + + + +--- + +## Plattformarchitektur ab 1.35.0 + +Vendoo wird als modularer Monolith weiterentwickelt. Version 1.35.0 ergänzt einen Plattformkernel, validierte Modulmanifeste, Lifecycle, Event Bus, Feature Flags, Policy Engine und Routenverträge. Die bestehenden Fachbereiche sind als `legacy-bridge` registriert und werden in späteren Releases einzeln migriert. + +Das Design-System verwendet eine versionierte Tokenquelle unter `public/design-system/tokens/source.json`. Light, Dark und High Contrast werden deterministisch in CSS erzeugt. Freies CSS ist für den geplanten Theme Manager ausgeschlossen. + +Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Policy. Unbekannte Policies verweigern den Zugriff. Externe Erweiterungen dürfen nicht im Hauptprozess ausgeführt werden; der isolierte Extension-Host bleibt als deaktivierter Feature-Flag vorbereitet. diff --git a/FILE_CHECKSUMS_SHA256.txt b/FILE_CHECKSUMS_SHA256.txt new file mode 100644 index 0000000..5a26733 --- /dev/null +++ b/FILE_CHECKSUMS_SHA256.txt @@ -0,0 +1,384 @@ +f344f75df655bc133e6c4abb2d215b2234fb22a4bb155c217317c36ee4f0f8fb .dockerignore +196b4f6848e886de83ce63810e60a2b129ff3e4e71f080efb9611432c4b845be .env.example +58e3f4b76cc9398f32dcd5481b2080e7e5ba322aca04b8b8374446299c5d7dd6 .github/CODEOWNERS +5bd335eb750d084ca12b6a3b3f35d0cd7bc454ba24ed280187c66acf75eaf396 .github/dependabot.yml +f65684e98b01d0e0b6bfe1284b5de3354559e4cd51e634fe9e9e2e5473b890e5 .github/pull_request_template.md +f037cc2a7fc97f78646cc834f35ecd28811ac46c960373f24ee2aff8a48558e8 .github/workflows/ci.yml +dea64cf55cb8eb8922b0f1156cbce5f4e853dfbc679d9f7b4058a7fd4bdd34d2 .github/workflows/release.yml +9e7decb6c419fc032644ad361043e69770104ea5904bfcce4942f9416787d975 .gitignore +9bfa732952ad571d06527b32d96d79133fc23fa607df00de440b2802f543608c .npmrc +4c424ef64a09639953388bd2e749d45e9a0d3d533c8b6fce547292237a0a48f9 app/architecture-boundaries.json +a6bdb9b6fdc97a0b2bbc052bd242a9885a75bcc46ee4f0414d8b7e215b6940de app/contracts/module.schema.json +7f38b3ac792e61e030c9f69355e2378f8f61e8e61c7b29c82cd0fa7ec930cfec app/core/themes/theme-contract.mjs +d84a573699e413e0a0803459e0d05c5c4e6e35d3499eb634f95302d151e75c4b app/kernel/errors.mjs +a34333cbc784d39b39a8071a437972c6175c551104dc2d82fc4c0e0506b18259 app/kernel/event-bus.mjs +0e8f691f96e1ef0a7a86d32cae7b7e1db04325fe94a32b8c1f484a5b6950f3b4 app/kernel/feature-flags.mjs +a0223a00691908862aaf1fd9383b6d86d67f26325c509280b8c2daa5cfcee28e app/kernel/lifecycle.mjs +ba1f4f799a508ff113738cce7ac9f5af27739cb60d7f04d7a6c5878de3c5b7f4 app/kernel/module-contract.mjs +1baa99ffd80fffcfed69f21a5db8911500f35c0bd9df23cd02a7fb3b563266df app/kernel/module-registry.mjs +3020a4f447f5af3d77efb918e5d074c79e3badea073e3fe0997bc6cf03bde312 app/kernel/platform-kernel.mjs +7944f68d995a5d70273f8a985973f668fc62a414d85636d92f3774e34d39a37f app/kernel/policy-engine.mjs +8fd120558114a0357d55b070c3a150464f57d6d87a84594c10562774ed994213 app/kernel/route-registry.mjs +0bb099111f7cf49780389d0b078083756a448b663661ee22475d84d38a5f9719 app/modules/ai/module.json +83ad66e0eba88246d292c96fb1ec02b9c59ed15614bbbf4a4a989787fe51e2d3 app/modules/auth/module.json +1e63f30aa7616f5582c2b1d151f5da29ae92dbfdcc5e69d516d8780950debf24 app/modules/catalog.mjs +083db2c78089db389e51e743078c847f1067f3d1a39081080c601d31556f15fd app/modules/flux-studio/module.json +82b31b548c603cdc4e21b50707fd3d21114b85ef1f61db607c881f9ac1809132 app/modules/listings/module.json +6453b41f4074b37c7f7a2c30aa8a0eba8c4233f8d474251919094ed0c15fe38d app/modules/media/module.json +9723a9b12a9b472b2ed8c4635cdce663b2f0f65643d8ae0b6219715d853f740d app/modules/operations/module.json +b71c0f607bbba70b4485fcce883a7d2ab4a533a6dc203eb7de616aa4d8bae435 app/modules/publishing/module.json +61aa66129d18e2c5e6c2e7319de836a4713fde1ddcc31c6a5ca56c22d22d9b48 app/modules/quality/module.json +1f02d199bac34cdfe2e41c32dfd450f2160493d9589b11eb1ca595fba74d5daf app/modules/security/module.json +452a5262c418b393bdd8754fe90f9ea14edf61338f8dcfd6438f0852920f608c app/modules/system/module.json +92d968c993ee5a2afc14df557b7cba552cae9a41722989007eb0b784514333ff app/modules/themes/index.mjs +1e062db871f9bf13b487c972f6cd4dd9cfe1b3046cd9b53ed43e0a0babc9d285 app/modules/themes/migrations/001_theme_profiles.sql +2af572390890e4d793aea9c1356c835917655c225a7848325d5a7c5ae3c08a09 app/modules/themes/module.json +3c3dc5974976672cd0e9141867a8a1d78a0dfaa54582bafb8f360bea571bf11a app/modules/themes/repository.mjs +4e2c348a184384739408894f26b02c56c6bfd4dd531eebb9c67d2fb7fbad3009 app/modules/themes/service.mjs +11b4a1bc94b68998a3a2a8bec766bc56c7ff829939d914b0ce896f963feb10ed app/modules/users/module.json +69ce3be6a8a4a28725c5c7f9dc8803415ab0d1e503047b66dfcc9a44c9e90109 bootstrap.mjs +a29b1b7f6fa480d9d7a801e41df54aab5e8e1e5bde52c69794ef22b59bcfc76d CHANGELOG.md +61823fc9fdc036170cce6edeecbb612b442d1c65fc9f7a7eaba47e5016316ee5 CLAUDE.md +4a3273ec603224a70ceb81c0d5ed6617c3ba2d3111342b4e646a79752f4b3f87 compose.bind-mounts.example.yaml +9f865115da62b2689e06156da34c97e10a0b9831a77f967f7f418e100543d789 compose.vps.yaml +0226a6661724b6a5a88a615dc7955f7c601930b7a3f6f4b328becd0f6866f46b compose.yaml +6323f984f1e04ab135356a14ec151941638041f6b305ed68772d5e1812972075 db/schema.sql +49690c4e4c8e84de8eb40ffe79823493b7be20fe46fa2ad9e6544f7f0afbfd2c deploy/Caddyfile +2e0077cd6dd83d3a07fcef25b7e37ab5fc056dbe1ca034b33d55135234045882 Dockerfile +cc794bca282d79a27d860d0bfef4641769f4e9f31172778a1d9d8205558d948e docs/ABNAHMEBERICHT_1_32_0.md +de0d86f806a70195050613f5b56f6c4da1ee2eff02a9a991b6aacee229c46ca5 docs/ABNAHMEBERICHT_1_33_0.md +947745174897470de0bebf4f7c2b29a0c0ef3f1a762f277374c2614d9e3dec4f docs/ABNAHMEBERICHT_1_34_0.md +26c87fa38f804749a9363812127dc33ef69e201e4ef9b59206f6fe6e15c845b0 docs/ABNAHMEBERICHT_1_34_1.md +f792d366c13c5a4a9b4e8d4054e26190d9c327a7a770005d74bbe2b31bb84ab3 docs/ABNAHMEBERICHT_1_34_2.md +bc638fd21222ada7b15e40adff80a318aebec58139978109cbfd30040815a8d4 docs/ABNAHMEBERICHT_1_34_3.md +c0ae5c4176ba6f7510be047d4feaf383cd646152131003ebccc6bfbf8c8056f5 docs/ABNAHMEBERICHT_1_35_0.md +c050c8e11f2bdb0a742851423dcff864bafba9cc839c52b2f8d48b75f7b18633 docs/ABNAHMEBERICHT_1_36_0.md +e5f065ba8d516a5ebd47e7e6859d0aab40ef69797413e358d969ef27c454fcdd docs/architecture/DESIGN_SYSTEM_1_35_0.md +cc0edf92756a51414563b4407dedb21d1e3b36682f4bfbbc334aeb33a22ae1b4 docs/architecture/MIGRATION_ROADMAP_1_35_0.md +eb47b055dd336e8360ec7c4712dee4ae37f109f3820e3f185969e2327d790cf8 docs/architecture/MODULE_SYSTEM_1_35_0.md +d7773faac0e93510a5a51c91de997df03547b51f3124267dcf137d4e99299562 docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md +771c4771c4d7335750c5a2d976c765b4ed66ed936d8550d7af69de14a16705bb docs/architecture/THEME_MANAGER_1_36_0.md +67a1be91b0c2d2797716f785f2f770c881cf88d7fd617722468df8d949d0638a docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md +2acba8fcc5da42e5a808925a86628bc0c6fc817ddb082b0ead3857c6a43460f1 docs/BRANDING.md +f23c643236c84b0e3715620598f358065748b6d090229003e23df8ad9320b54e docs/FULL_LISTING_EDITOR_2026.md +ba9a9b93622fadc399cd9067d80b4ea339193eb19825106264a3b86b7128d107 docs/GENERATOR_STUDIO_2026.md +54f45ae5c2f12b6314b3f1bc3e59e753c64ca7cfe39644725d765e9f02f637ef docs/GITHUB_DEPLOYMENT_1_34_0.md +5a25e952d7d79b562bc19c990451830ea2ac3e730c9af399425ea18ffb8fd61d docs/GITHUB_IMPORT_STATUS.md +65d19c1576f4a6011008a7e447a3530222517752fb59ebc5bbb506b791e32605 docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md +c9c8f934a0c4192dae29df913588188aab1321364d1c46b9c66da5c754e9df41 docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md +2306ff399000c07a9d7e260a58d92c748fa3219922a4dda25f6b2121a1ad2dbc docs/HOTFIX_STUDIO_FLOW_DETAIL.md +0584257245a29de62a6374eb8b114bd19fc6a36306ed990d820ed2062514d6a6 docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md +7c1afdefd1e3a75a5d6b49aedae80e266bab4e91cbfc657fb862ddfd737dc792 docs/INSTALLATION_1_33_0_MULTIPLATFORM.md +1475e484cccb0db67e02abbb05f8b8e676c85536ec4f5bda7dd99d4563fbc049 docs/INSTALLATION_1_34_0_ZERO_EDIT.md +1df2aed413865c03e541e66249c1df3eed6d3c3aea9f0ffdeaee40453665df14 docs/LISTINGS_UI_2026.md +4f74fef6877126d2579356b9731955e29f05ea8b55b59abb91569e47730a1c3f docs/PUBLISH_CENTER_2026.md +bae236366d6d2cb0139e4dbf8baf099599bbff27aacc9eeeba20fdff644f5fb0 docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md +5223408e51f35ba5f6ce86155544311dd13f60522a6d482f372d64788ab466ac docs/RELEASE_1_24_0_VERIFICATION.json +b66524ffd19976f650c6b26b299898b182355950f545c131de16c89fcffe0ebe docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md +1c5ebf1a496c9571fe9d941793371f9f0be89d85a587c8fa9532f9b25bba92bc docs/RELEASE_1_25_0_VERIFICATION.json +1eef86e13d9429701e9d60894f36fe0eba82c974565b1a6687ed157ca6da680e docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md +14475ae4169f4bde4a5b7ae5512867f972ede69dcb621d7cc6a208d4fdc6a331 docs/RELEASE_1_25_1_VERIFICATION.json +f02b80194f434dd3d82b5c4c111e8cae65f1f053a267cec2793a1ff551daff9a docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md +570522a6513134227122da1b5f2f56a421c62d3b0b5adba703872e9026fa2b58 docs/RELEASE_1_26_0_VERIFICATION.json +4cfc47f35765259cbd6a841f51a85fa86600991d1ca545b10756f15b6d332f7f docs/RELEASE_1_26_1_STABILITY_GATE.md +9646b913994b1bf8ce03eab57e7a6a87061540e44fdde63ad5b7eee93ca9f475 docs/RELEASE_1_26_1_VERIFICATION.json +7c6c3a2e4ce478f47e540a34a3505dbbe11b8f2175d0b274bec7643457997140 docs/RELEASE_1_30_0_OPERATIONS_CENTER.md +6f9c206869ba94c461b1d9f8e40847792b8891bb27809a0c6fa3d1d68a4e8598 docs/RELEASE_1_30_0_VERIFICATION.json +5da62e06d768ce832d18fb3ecb3ccca2f051a6218d2d3cb492d1e5aebbcccb52 docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md +6288a31dfa61f71d13f79d40ab64bb7ca475de07ada154c002cd4b0d44a284dc docs/RELEASE_1_31_0_VERIFICATION.json +4ee88ff8c3c655cb31bc18192a37ffdd0eedf8e26039b252094d5ce169c1883c docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md +628d4d1a6c7d837a0c8c22958253193642ddfeaab5f425f418f9f32b46e84340 docs/RELEASE_1_32_0_VERIFICATION.json +624919ea60969079f767068771800dbc04ea7a87eb1a53d7f0497447492c25cb docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md +3f9ae39792bec600ddd534284ae62cb2b4f9db01fb54c09cee8ae161551caa2a docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md +c724b699894d5ec3c050af0253c6de0c645a789219a344ad7ffe31e330d9921b docs/RELEASE_1_34_0_VERIFICATION.json +7caa9e7b1e51af362332e9c50165b60c54c9219c7384c8313d3735c387df1c01 docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md +ac834ee57ab6bd8fe3dd0238837193e66e01d7e78590a289347b002591da3fb4 docs/RELEASE_1_34_1_VERIFICATION.json +51057d17e2636ef1bdfc6751dfad29ebd34fbe7f41b870f8f2f756a5c97d1c4a docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md +4c2c141ffc85b1d7517672abf2b28bb19fe4769505c0eb4d37e22bae06826cb3 docs/RELEASE_1_34_2_VERIFICATION.json +d55eaec79e10800a930fa030367ffa34037755e5757bc532e72d25711c5ca09a docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md +4bb731af51e786a4827ec48bc7c4d4843c287456aedc09d8b74aa0eed3c0ec16 docs/RELEASE_1_34_3_VERIFICATION.json +e7bb3bb950e3c5303746f43139ca8f7c985d2d73896b44f5b707dd46a9152529 docs/RELEASE_1_35_0_PLATFORM_KERNEL.md +33eb9f0059a2ddc4b4debe5bf733720f2a5bb22f2710bab0a0cc9ac1b898ff11 docs/RELEASE_1_35_0_VERIFICATION.json +5a9fcfa0ea6e9c5c13f6d943e1de4d31c21e21fcff9186e8a714d7a16ed1c18f docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md +2dfe705857424865f101e861f4e633c8a7fb3c0e8dab874cb6620fd09b1ca902 docs/RELEASE_1_36_0_VERIFICATION.json +fe5756f788b2ab131f0832504d90d46d1ed744bfe470fd040803985234afa3cf docs/SLICE_02_ACCEPTANCE.md +05614ecf41becc399acfa6a3b2f681f37a8c80f5ce0130b98c578b2c8c60713c docs/SLICE_05_ACCEPTANCE.md +0838bcc6464a6beff39f0b4b056e513c1a390193521d9a8fc5d440d38034e9d0 docs/SLICE_06_ACCEPTANCE.md +ae0f7c6f6e614ec77e362db1076288596ec48d11b78f33f26908fbc40ae5dd18 docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md +b561983736639a08146d3642b718651907853d589ee32e5f421fc090f2c75a20 docs/SLICE_08_EXTENSION_AUTOFILL_UX.md +c5bf7a0c9f787f100e422d0deb27bffc2c8c86b0e7c1ce1f35d2de8d7d8ea774 docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md +876675bc3015838f568a6970ef6c01c9fcafe1d8d138c4379bc0f2ee1fd7445b docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md +223f50d05096b3bfa3ac3a9f0880a2055fc2029b7d70fabd086612c3348cb34a docs/SLICE_22_2_DOWNLOAD_RECOVERY.md +95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269 docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md +88f5a2d021874764b6a7f56c690a1e2112155b83cdf0d4d964b2c6e00ca756de docs/SLICE_22_3_SHA256.json +791a5d30f0d6ff9664722a2d00dff0db0bbdf2207e83ab391a40d6c4ab913bba docs/SLICE_22_4_SHA256.json +d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807 docs/SLICE_22_4_UTF8_BOM_FIX.md +f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe docs/SLICE_22_4_VERIFICATION.json +e140f3ee3298b3894126e37900b04ff1073ed400e6414e35897815b3f674d93e docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md +b4b01b9f3751940d184d6fff269108798c7ec62ca7796b923cf82abbc142ff3b docs/SLICE_23_VERIFICATION.json +05e3fcf5aa9a0d8d53175a3d24c5feb36e55f73edfeadf05384e87a5de73353a docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md +43d867bb8b455f7450e5146648b28c759e50ebab45855d75a36dbc9f91d3f654 docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md +0bdbeed4b57767427afbebeddc30ea58a211f851663939d6301af38820cf098f docs/SLICE_28_VERIFICATION.json +1e20fd1abea3ce61f7f9cc88c6e8e4ddb0fe3513c5600e41b8f7c3f8d38e3ae9 docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md +1df7305e6a43212a582d5f83630e0ba04fbdd3da0d673d9dcb8f1c5f7ca37731 docs/SLICE_29_VERIFICATION.json +07d1ad0c19567cbdb05623567816f1506197a31e86fbcd1742f4ffa300335ae4 docs/SLICE_30_FLUX_STUDIO_PRO.md +008237388c4816d3cd67f3f0abfd0512f4ebf2dce75ab6790de41b0481ae1d97 docs/SLICE_30_VERIFICATION.json +db1674505e2ca7eb36c8752ea9b2d6fe2f2c12a5e6a1584e2722e20445b05e34 docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md +3133a06e68cd98e615797665956f1bc401c0427045298b46c48ca590be3ef9da docs/SLICE_31_VERIFICATION.json +d425fc1724ac55853bf3433348486de8f8aab111f53fefee3c532beb1deb82af docs/SLICE_32_BATCH_IMAGE_FACTORY.md +0a8917412d000168956f7ac55e60ab79b54a8834deef0386a681bb8f920514cd docs/SLICE_32_VERIFICATION.json +25ff8492360d473e99c75c308be5f75cf84e0c11574d42aa6553d44f8bb35582 docs/SLICE_33_ARTICLE_QUALITY_CENTER.md +1d5411f69c62222d3155f0e89f2b4b5a3effe317ea1e59347efa7730a8e1b0e9 docs/SLICE_33_VERIFICATION.json +1161decfabd73c40ff1d2ffbc7d1b9b5168a8fb5fa392187c1b431e9a7f3a78f docs/SLICE_34_PUBLISHING_HARDENING.md +aa7ebc90470071566749c61b0dece4169f87666dcca4b88922f7fbafcfdd5a84 docs/SLICE_34_VERIFICATION.json +cc77a92ca268d3c444c6113a9cfaa38d3c805a579f6b1b21b7dd43a6ada58551 docs/superpowers/specs/2026-07-06-autolister-features-design.md +4b66a14b4515e38477f10325ab9d03d9782ad0d426845aefe2d508986ed2cd81 docs/UI_FOUNDATION_2026.md +5e655fcf3bb1182f8850b4104b56697ea952b01d2698af6d3d40b05e338b3506 docs/UI_UX_FIXES_SLICE_03.md +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extension/background.js +b86f00c7afa1dc921dd66f5cfea8c817eebc8876b9daca6a55dfd03680156c79 extension/content.css +8ee8d1a94bbf83e5d09aee3c7d8c4ea5f216b2a51e73b6dbc74f7e6b99017a1c extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extension/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extension/manifest.json +e61fd8e1264995fd633bd76e71329813dd02f361286329832a18dfd5a8bdd372 extension/popup.css +bdf5b794473cae818789cf95a28a9c9a9d50f81529d72fdf45a536247a783922 extension/popup.html +d7893cfdd686d4a6a1bea333468d4ba72cfff0e7166687181a2d37ba1faf3332 extension/popup.js +c7db96df5fed3c8336062204322d62b4870f5c8ab1d30ea48cc6e7e38ed3513b extension/README.txt +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extension/vinted-categories.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/background.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/chrome/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/chrome/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/chrome/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/chrome/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/chrome/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/chrome/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/chrome/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/chrome/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/chrome/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/chrome/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/chrome/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/chrome/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/chrome/vinted-categories.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/content.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/edge/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/edge/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/edge/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/edge/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/edge/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/edge/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/edge/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/edge/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/edge/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/edge/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/edge/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/edge/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/edge/vinted-categories.js +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/firefox/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/firefox/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/firefox/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/firefox/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/firefox/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/firefox/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/firefox/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/firefox/icon.svg +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/firefox/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/firefox/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/firefox/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/firefox/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/firefox/vinted-categories.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/icon.svg +c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/manifest.chromium.json +1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/manifest.firefox.json +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-chrome-1.9.0.zip +e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-edge-1.9.0.zip +74c219587cf0f827a1a1e2b8adacac55477be717fd4ae80e2ae027a9ffb0744d extensions/packages/vendoo-link-firefox-1.9.0.zip +9e828844f41154c87ccfc5d8f3a19476396e2782fbd0f956465b42fb508cb098 extensions/packages/vendoo-link-safari-1.9.0.zip +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/popup.js +48b5f9f4ecc1c1c42f37321e9bb2fc857c646b9c16c34520fc7d4ca59a1036ac extensions/README.md +0b2a03d06faae7aaebeee8ec9fe1864422c8651f17ae90b185fd31474ac5eeb0 extensions/safari/build-safari-extension.command +afd76deccd8bcf26d39f8c86deb1de578873ddd8b09ac18eebacae33169bb420 extensions/safari/README_SAFARI.md +3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/safari/web-extension/background.js +c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/safari/web-extension/content.css +22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/safari/web-extension/content.js +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/safari/web-extension/icon-128.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/safari/web-extension/icon-16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/safari/web-extension/icon-32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/safari/web-extension/icon-48.png +5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/safari/web-extension/icon.svg +772f1e2e33f4d6d882fa0af663e315fd3b8208141227bb5beb9baaf1f4e08397 extensions/safari/web-extension/manifest.json +e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/safari/web-extension/popup.css +559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/safari/web-extension/popup.html +2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/safari/web-extension/popup.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/safari/web-extension/vinted-categories.js +273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/vinted-categories.js +3a72c2ba86fca6655af49a47e853a335532586dbf5ea620d909570ec7d76cb02 FEATURES.md +7f78a0d512de717ba76c9a5e3e1bd2e52db9edf146e8eeb7297458fde5d92afa GitHub-Deploy-Assistent.bat +9a5bfcafdce53130d7bc36695090a8f87096b4ead9dd5ccc3d46d1d80c07a0fa lib/ai-claude.mjs +b53b7a8dea3bd46bee9c32e2ea840d618b5cb31cceba49937c057ec615fb87b5 lib/ai-flux-prompt-jobs.mjs +347778ce521a1919ec0679918171dc625d7f04d6dec26c8a4700e702bc78de67 lib/ai-local-images.mjs +aeb5103c0179232b9954b7854be8099cf4bb0f3ef1ee908eb5e4faf6a8f1205f lib/ai-local.mjs +cad43695655fc1c805815d9d694f23bd41124c1b78bb6493e7fea8893c464320 lib/ai-model-jobs.mjs +9f474f665d6d610fe00d9e707e85314a854abb906e31db2bb9a3a7c76785ff4f lib/ai-model-photos.mjs +3fb484456b9e6374f14b53a2276eea8533642b7c9073bb73a1bb0dbed7048bbd lib/ai-openai.mjs +581c10b8346d0aa10169ed23ed49c038951ecd5cac014722c1080bbd5aec1c5b lib/ai-openrouter.mjs +271696d7cbef3581b667a8ce3b07d0b17b1e8b467fbede4d8aa512ecc5225515 lib/ai-router.mjs +76a2f1fdf60053739d82962264219060807022dfc9a2b3a644946fddff02a8d9 lib/archive.mjs +ee6aa662a1875f9df8da1000804a6061f1a0f52c7a266df3d515cab4d410465b lib/auth.mjs +3e2b75c0758703e590233cbcc9b589e3177134accecf5ed921791891cbfc8522 lib/categories.mjs +86793fb57a1dad32f4d93e34c27ffe9e77a95785dd30b2b55ffb67d0ad5e14fb lib/config-store.mjs +8c203e70e9ab07b996d4a53031892bbf37b10efd024f9e0013266cf2af0244a4 lib/db.mjs +75827cb79cebc95cf9ae25469efe28c04cebac2900b62f731552329f0c055e91 lib/ebay-api.mjs +b4460619cb14a4e4b810adc78bdd662c9d375c483c979ef619f8b95dfefd3f73 lib/etsy-api.mjs +5b2c566ff14fa502ba2cd4d4c918fe238a8e93c3283d521144093a897f9d8018 lib/fees.mjs +7dcb2fb5606140afb7a019094d20ef88e9b4e2c70041f77b06d67d8992fd25ce lib/html-templates.mjs +844f0f8bc1de849bacc8df5d57006d5fc1100c706acc1140939a981dfdf2df4b lib/image-batch-jobs.mjs +5ca5fc65a7de7c27243646d291dc42c0b5267a47705a09b306cd2c2eae2a77c8 lib/image-editor.mjs +5e7aa66faddd3d19bc6ebb9ddc13abfc4ab4f17d8c51bb39b2c4e0279385d788 lib/images.mjs +0248ef19a04e8f1265cf89c170875c3a8051c91c6151b762a849cb55104e93f3 lib/mailer.mjs +ad53b2b39f894a685f79e723b3f2b987fc9d9a3b75d39d0059ad225bc36c08cf lib/operations-center.mjs +78d30585aac7dc663e803a19927c89ab02414c0495d81df503e28f7bae58701a lib/publishing-jobs.mjs +3a728417dd668d60fd1526ec738cb9b302f86c01d62b2227f12b2e44b7d389c7 lib/quality-center.mjs +faa5d84ca6b26d1602e53a402de74a5c9e3f9ef9a72233159101f2b0bb0e499f lib/runtime-paths.mjs +cbd60791ee86fe09f07fc9ed85be3654f7aa5a310baafd9053440952619e2bc9 lib/security.mjs +ddb9c7677600795a4adf8a19a7525b7551455459d16eed4cdfbe751ef9ddf0eb lib/zip.mjs +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 local-ai/start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 local-ai/stop-local-flux.ps1 +5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265 local-ai/workflows/vendoo_flux_schnell_api.json +302ab1609d08b3059ba6563b676c64f0709651f2e3c531857bfca3840c535a79 MockupDesign/AdminDashboard.png +49f7625a1299a16576feabfd7824ec3511391bd9b7c53a1fba9232da7e8bd6c6 MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png +7b8cc9c5db8718b0b653ec6aa9d2a8bfa1bba03810248c3ba17745b6f15cc412 MockupDesign/Dashboard.png +4e3e797ed5d3ed0b60d00d8e567fbe51543c08578b858ef71e77b274ae449fd0 MockupDesign/Einstellungen.png +c78fd6f673c0e59789ea313396b12c2f21f75a2ee6c7ad2de81a6b89f6d90588 MockupDesign/ExtensionDesignMockup.png +ed20bc04cab8e85245a855810a61b736a682f972eb85e23340edc9b558851a93 MockupDesign/Heute StudioFlow.png +1ad88f45f377ab9b3f35f96a0ceaaa7f75cb0a996e83370d76c54f527392a08d MockupDesign/IconLogoMockup.png +0e8b4a31d075f1f83ea62b5eeeb53e89859e395a696deb43155932a23e7cef86 MockupDesign/Lager.png +b1c62e118d9be316274e966c558cdcccdd41afb3a9f05a60ec1e4a3433d2ca0c MockupDesign/Listings.png +a52983b9ea2bf920d115485d10bc30226da88bc5cba8b00bebf801b21c096346 MockupDesign/Publish.png +8c8bf278d0c6725e1dbb97b3c3d0c36a9bfc0753c3c86433fdc294bec70dbc0e MockupDesign/Startseite.png +62f14221b1d71c9e631efdadd221397ff657adae4b737df52c8bff1610b90999 MockupDesign/Vorlagen.png +2571f42e92a40e5483c762b1489b38294627dccfeecf388ac2b1156d82666d8b package-lock.json +098e7758e0cf9fa92d2ab1e9c01c62e59196576c79d845292b8a36ca4ed6960a package.json +3da71dd28cb25c7c7317b9061855d40c6a3ab8bc39391963b49ee1e707f838e5 platforms/ebay-de.mjs +cc1ca2b4384c8c548bf755dc33101d248541bf893c3d5f35369961f904afcc00 platforms/ebay-ka.mjs +fa52b247dd758aeee06120499bd1ae6c53e9f460f8bd7762b8f06141b201e32b platforms/etsy.mjs +5c925e3934a4c9e2f26dc15b3449a249f5ce20ecebbd459b15365a34804824d6 platforms/vinted.mjs +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/android-chrome-512x512.png +90689f16d004d8ddd3ae4edd5ec0ca1bde89901fb36de163aadd10d710bf347c public/app.js +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/apple-touch-icon.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/favicons/android-chrome-192x192.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/favicons/android-chrome-512x512.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/favicons/apple-touch-icon.png +a0f1e01de4e8215e690c1ce869fecaae9d7ba3cc95883340a53ff38f6be52ce0 public/brand/favicons/browserconfig.xml +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/favicons/favicon-16x16.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/favicons/favicon-32x32.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/favicons/favicon-48x48.png +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/brand/favicons/favicon.ico +2daa93cca0ad2c0cab90a9e8ad4ea0ae4b64feeb68c422954020d17e2376a381 public/brand/favicons/html-snippet.txt +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/favicons/mstile-150x150.png +c372675942caa5010340f21fe521adc72af537d909008731976820fd84604d42 public/brand/favicons/site.webmanifest +b2dc767e7a01395d23a7f3f5915324db1a7d1912bac60470d2f7c10815895173 public/brand/icons/vendoo-icon-120x120.png +409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf public/brand/icons/vendoo-icon-128x128.png +d276f50b298e4dfcbba9147550463aee498531280399167d1e9774aa8e5f7e21 public/brand/icons/vendoo-icon-144x144.png +3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/icons/vendoo-icon-152x152.png +47122dc91010721e2692d11986f0b91d9b76634130a95723e92b2a070268d16f public/brand/icons/vendoo-icon-167x167.png +8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/icons/vendoo-icon-16x16.png +e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/icons/vendoo-icon-180x180.png +a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/icons/vendoo-icon-192x192.png +3f2517cfd215cb624ed639d60b064dfa5e88ce71eb7bbebbbfaa70b3d62556db public/brand/icons/vendoo-icon-256x256.png +9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/icons/vendoo-icon-32x32.png +cae3eb737c8ad3a82a679b09d33d991b501bf482e0132e99aa1b8ad064e472b4 public/brand/icons/vendoo-icon-384x384.png +64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/icons/vendoo-icon-48x48.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-512x512.png +bc85af557be532dcce6beb82cf9bd61af683666b00fb61e4b2069f78e60a9a33 public/brand/icons/vendoo-icon-64x64.png +01d59b665bc90dddbd34221e3a2ec3af11bff6fb328aab2c9947c03d74b0465d public/brand/icons/vendoo-icon-72x72.png +ed3dfa59e989b154fac88a3c72ac840290ee3e1565d44aa75596bf0055c74020 public/brand/icons/vendoo-icon-96x96.png +46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-original-1254.png +8fd9901e07dc9172d162706511ac8dcce220a4c907cb3efa9940c9c13fa32526 public/brand/logo/vendoo-logo-cropped.png +3f39371abd1efa6cc8366aac9a6d067f3339b8f2a2da77ab8a26c6bdcf8c4fce public/brand/logo/vendoo-logo-original.png +b56d07515f6220de7fd0cb149fed598a7361c985ae6ca7cb14329a99e8947aee public/brand/logo/vendoo-logo-transparent.png +e83921d2af9930ab27df1abe64374cb9311189bfaaa951068109177443571a07 public/brand/logo/vendoo-wordmark-transparent.png +1711a9387f2d6e75797b77c8daa92b94d7a15e128476e4b8aa9d54ccb0692a0e public/core/api-client.js +f30395399a5c6d7e0a884b22f28f7648e6343695cee9960d6ff13b34de7b48f1 public/core/frontend-module-registry.js +fb3dbcd03f7ed15b773b582c2a2b785c3040d3319aba5b7c682a7e74e1cf3b4e public/core/safe-dom.js +38aa57d13aeb3ab70babcb903bfee71e47d80932ed24a73267c13ff81c24e3ba public/design-system/theme-contract.json +308f1c0e2ed1de34aa11545a1a660aab02631e422d6c6945e3203d11666784fc public/design-system/theme-runtime.js +4d69e2cb6b7f9f63b54d708a5d8b1ebefcdcb9f88a977053db5031c05b098464 public/design-system/tokens/source.json +4a9e65edeaee3421edc3dd4831230062494f1a42f8495f4cb979fde8664d06c4 public/design-system/tokens.css +15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/favicon.ico +6e5ba2d8fbe559861428ccf4ebe7e8d1c82357d8b29b7548e40398a605f17c28 public/index.html +141c261b2d0ea713af140d2e89f98b9c9046584bef60e97d8bf3d6b6f504efa2 public/login.html +59291f75eca405e3856bafea76cc601d681060eb6b36bf82a4be3c3ab7eac290 public/mobile-upload-expired.html +f046f166d89fc32774b87deb02e6d144fd2f40c0925aac67487e5b7c489a0ec1 public/mobile-upload.css +6152e4fe715efbf8fd6a7c4286f23172bc3cf15dde085e5e4f7389ad5e65ad73 public/mobile-upload.html +f13846801a8493aa43df6ec3d78b826b21b59e8f21d47735f68fd0b278e440c7 public/mobile-upload.js +01f2bd124257220005032bf234d122b9942fdd8fd57ed8195748b2fb1f5a5cfb public/modules/README.md +2d7a004db3e22b30ad3a682ba445bcee3fc1c5f0ed5e8fee45b5892c5b335e51 public/modules/theme-manager/theme-manager.css +0edc61f0c2bb604c3adc40813b18fe5ca8d9b846ec2a3d9f406ae26ae313d3ee public/modules/theme-manager/theme-manager.js +a26f11bfb9627e878961feacf6d56211a40746731a63a7649bb327a383d60639 public/style.css +ff525e0af963e6f54636cfdc1bd2d045aaf82e7b0199755eb5cdf4c52110be2e public/vendoo-icons.js +da56aefa73aaa46fa2c1931ddc8075028b75fae68def3a03d2af98c4b1d8db65 README.md +deb1a45f6414a2357046ff1d66fc58114549ba5ad695ed40fcceef2aef9f64a8 scripts/docker-backup.sh +20e7590a7633b6c61086a5596d0bec1d343f0ff0eecc444ac932130ff363e258 scripts/docker-entrypoint.sh +be72c39f00415cd57b69835346f00043841ce9f853b10436eb1799ca42980cb5 scripts/docker-restore.sh +3f1005ade493905e37f90314e8b4667189f28297acf88575fee3d2fa5df00216 scripts/github-deploy.ps1 +1461b07a51d41c72be59b3903d36937d5912cadd600d8519142c47478d0d40c3 scripts/github-deploy.sh +0a9bafc79c1c5d59fb9b51ebc34c00eec4a4e9a5e873ce1818f50f3ab983da70 scripts/install-local-flux.ps1 +2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 scripts/runtime-start-local-flux.ps1 +7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 scripts/runtime-stop-local-flux.ps1 +1a5736cf5fccc991df122163977c2eac80281bd99a54a5680b3d7eae99e4b79d scripts/start-local-flux.bat +3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd scripts/uninstall-local-flux.ps1 +332011f0281e4fb21397ccadf6e7e37ceef13242ad43e5a87ad55fa625b3009c SECURITY.md +aa58e3380d0e5c8e5df2bf7d09dea4cfca996ecc1991c11e2518e840f72b8bf2 server.mjs +a7384ecbf3977245d31097ea4ff224142d998c3e80d0e44f49aaa8a2d4395728 setup-gui.ps1 +cf4519ce5adc01f05458e3286e86f3a419cceaff5fcdd869a1d07f1c1d1a0d8c setup.bat +5b3d8fb189b7efb656a64fc41c1bf0cde2202ac91aaefbea74c927acd9d9727a setup.ps1 +bf3bf58517beaa27e837f9b5c75d0149b640bba8cf4df6f0a6b2566fd36d7193 setup.sh +2851c914a1b50a036cb265ddd32e5545cc7e8e34ba9d682445926acc23eb8f28 THIRD_PARTY_NOTICES.md +90485a16e3468cb9a6750646700fbb036cb9ac410e9b8bd5c582f7b923bf3c31 tools/build-release.mjs +ac8440bce2659a035302609722f1dfed5d125223376cd575ffc0636397c94b54 tools/generate-design-tokens.mjs +294659f2beb607540abbc3d6b4a1658525448a42f8880f2b282081a259c4e35b tools/prepare-git-staging.mjs +8f9ede5f112ee03323db3a6c73aee00b7037f5139dad241b11baf5f0e9cee4e0 tools/verify-config-store.mjs +5752f6edb956696781da8bddb1c1848d26941fbed5a524691653c84c88c34bff tools/verify-db-migrations.py +3745de55a110d8254983b096c6afab196ddaec7f1153cd2f34c1e480b15aa33e tools/verify-deployment-1.32.0.mjs +44f6b349b722e5a83d4725c4879fa1a9383f59ab5634c49d3d7d9d7aa0cd5e12 tools/verify-deployment-1.33.0.mjs +cbd91b45d97e11bd4f5ca2f9d2edf2b5931cb2aa336cb71afc30aad9cffbe1e0 tools/verify-deployment-1.34.0.mjs +01a43040a95cdac40703f49031acd9267cdba99d9027a51718f35ffb643237fc tools/verify-deployment-1.34.2.mjs +906767e574c06aedebb2d7797150bda2f1e4c4536e8f82f4c7de2e3cc8398c8e tools/verify-deployment-1.34.3.mjs +71d00227a59592cc0ad8ba321c63350ec25748ef965d8cd0eff1317738c7fba8 tools/verify-deployment-1.35.0.mjs +b65eec7fd54e1459b363d99d20f9438ae5bc4b518e775de8b97e33e89a382e42 tools/verify-deployment-1.36.0.mjs +1f052446f6c9627420afeb48aa09771b61f9e08ba573f6a0651b2843ef0b79f8 tools/verify-git-safety-regression.mjs +5bba3d7ae042b0bcf4eb56bacb3f6e41e1b0e00cadd7666505792ba6748cc987 tools/verify-git-safety.mjs +2f8d687319619240efe5849c276f7723e69755c66aa3b9609e41fdf0cda01e55 tools/verify-git-staging.mjs +1d4351e4a827e718766a1677ddfbc3771a91942d0d5a93753e39f1b99c68a4dd tools/verify-hotfix-1.23.2.mjs +44c95844422be3deaad6054ee3f0cced9c25986815d976e0c64b042d0c2c5e1e tools/verify-installer-shell.sh +cab03e98687af87f359f6424d440fc0b644da97b3c614509b8c7d008d4b18a8d tools/verify-platform-architecture-1.35.0.mjs +732bb15431746a829e45ae55c695360cbf1dd21d4fc3be26a98f35708d571e84 tools/verify-platform-architecture-1.36.0.mjs +36e35fedb08923c22fd8551389d33432b66dbd4215d95c594ef29bfc790c98e0 tools/verify-release-1.25.0.mjs +89621694b7bad18b6f3f1ac1956032f64112c40d3edc069ca581dc813d57d628 tools/verify-release-1.25.1-static.mjs +c908f5186cf2d71ef83d5e649327ae19965568933acfb32057ca00d29cf890bc tools/verify-release-1.25.1.mjs +e31a055d2ebb8e439f5b0e0235a71dacecb891e299377cfbbcfa8a32f53a85b0 tools/verify-release-1.26.0-static.mjs +7e107b5f9a1b4ee381ff31105feb6d38dda5c40e4afe105a955badc36e6b8d10 tools/verify-release-1.26.1-static.mjs +d9a75c45f40d857c84790d51dca2fd088c3851ca250405871a7e32773918c66e tools/verify-release-1.27.0.mjs +3989e3ec236c1aa4c387b26cc15e04cf200a018e29c095321acf826c6bae4d66 tools/verify-release-1.28.0.mjs +7d196d913a697248ab1233907fed56c10329cf201e3bdc173bca73cb02755369 tools/verify-release-1.29.0.mjs +3f30260dabbdf0fa7b042381cb9e24f840d0c3ddbc44fe309763789a7d14afb7 tools/verify-release-1.30.0.mjs +b1ceed5dfc17dbd3b038b7168e61076e4d39a0f1d67e2b87677a91c4dbb2daff tools/verify-release-1.31.0.mjs +a372258b97aa68d59a1fe0fd3f380d019c59412d5c408a6064f437d5cfc6e709 tools/verify-security-1.31.0.py +31a120622ce8bb8451014f2662366f81abd28d0f3be59adfe35d17e0ada1838f tools/verify-slice30-mock.mjs +20957a4a038b9afdb01a8c98b881644daf793d2a6bb778a4c8e04780687d00e4 tools/verify-slice31-image-editor.mjs +21061ed5dc88feef48e80efffd9a1054d38598dce5a5d3f9f6bde39b539b83f0 tools/verify-slice32-batch.mjs +0f05b1ac3b686cf535bcfb3e6f6df24a29c3429ade1b3c666c1b99abb1a7311b tools/verify-slice32-image-render.mjs +c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 tools/verify-theme-manager-1.36.0.mjs +0f3f684019763a14f5fbaec42e42c680cf748d930d46cd607fc61eb413ca4553 tools/verify-ui-contracts-1.26.1.mjs +d677548be9b02ebd3b53e93158aeb740a11bd3e6d52b07898f1908c74a22294f update-manifest.json diff --git a/GitHub-Deploy-Assistent.bat b/GitHub-Deploy-Assistent.bat new file mode 100644 index 0000000..65b2213 --- /dev/null +++ b/GitHub-Deploy-Assistent.bat @@ -0,0 +1,14 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +title Vendoo GitHub Deploy Assistent +cd /d "%~dp0" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0scripts\github-deploy.ps1" +set "EXITCODE=%ERRORLEVEL%" +if not "%EXITCODE%"=="0" ( + echo. + echo [FEHLER] GitHub-Deployment wurde mit Exit-Code %EXITCODE% beendet. + echo. + pause +) +exit /b %EXITCODE% diff --git a/MockupDesign/AdminDashboard.png b/MockupDesign/AdminDashboard.png new file mode 100644 index 0000000..de4f1c6 Binary files /dev/null and b/MockupDesign/AdminDashboard.png differ diff --git a/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png b/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png new file mode 100644 index 0000000..ba31be4 Binary files /dev/null and b/MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png differ diff --git a/MockupDesign/Dashboard.png b/MockupDesign/Dashboard.png new file mode 100644 index 0000000..4a388a8 Binary files /dev/null and b/MockupDesign/Dashboard.png differ diff --git a/MockupDesign/Einstellungen.png b/MockupDesign/Einstellungen.png new file mode 100644 index 0000000..7adb09c Binary files /dev/null and b/MockupDesign/Einstellungen.png differ diff --git a/MockupDesign/ExtensionDesignMockup.png b/MockupDesign/ExtensionDesignMockup.png new file mode 100644 index 0000000..ae1f76b Binary files /dev/null and b/MockupDesign/ExtensionDesignMockup.png differ diff --git a/MockupDesign/Heute StudioFlow.png b/MockupDesign/Heute StudioFlow.png new file mode 100644 index 0000000..a3d3c85 Binary files /dev/null and b/MockupDesign/Heute StudioFlow.png differ diff --git a/MockupDesign/IconLogoMockup.png b/MockupDesign/IconLogoMockup.png new file mode 100644 index 0000000..d731ebf Binary files /dev/null and b/MockupDesign/IconLogoMockup.png differ diff --git a/MockupDesign/Lager.png b/MockupDesign/Lager.png new file mode 100644 index 0000000..891ee35 Binary files /dev/null and b/MockupDesign/Lager.png differ diff --git a/MockupDesign/Listings.png b/MockupDesign/Listings.png new file mode 100644 index 0000000..9e6679b Binary files /dev/null and b/MockupDesign/Listings.png differ diff --git a/MockupDesign/Publish.png b/MockupDesign/Publish.png new file mode 100644 index 0000000..47aab98 Binary files /dev/null and b/MockupDesign/Publish.png differ diff --git a/MockupDesign/Startseite.png b/MockupDesign/Startseite.png new file mode 100644 index 0000000..77735fa Binary files /dev/null and b/MockupDesign/Startseite.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket.zip b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket.zip new file mode 100644 index 0000000..d95de5b Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket.zip differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/README.txt b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/README.txt new file mode 100644 index 0000000..ec123ae --- /dev/null +++ b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/README.txt @@ -0,0 +1,15 @@ +Vendoo Logo & Favicon Paket + +Inhalt: +- logo/: Hauptlogo und transparente Varianten +- icons/: PNG-App-Icons und allgemeine Größen für Web/Mobile/Desktop +- favicons/: favicon.ico, PNG-Favicons, Apple/Android/Web-Manifest-Dateien +- social/: Markenübersicht / Guideline-Sheet + +Wichtige Dateien: +- logo/vendoo-logo-transparent.png +- icons/vendoo-icon-512x512.png +- favicons/favicon.ico +- favicons/apple-touch-icon.png +- favicons/site.webmanifest +- favicons/html-snippet.txt diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-192x192.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..d1f5496 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-192x192.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-512x512.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-512x512.png new file mode 100644 index 0000000..c237302 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/android-chrome-512x512.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/apple-touch-icon.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/apple-touch-icon.png new file mode 100644 index 0000000..8dac099 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/apple-touch-icon.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/browserconfig.xml b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/browserconfig.xml new file mode 100644 index 0000000..c5b7fc7 --- /dev/null +++ b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #faf6f2 + + + diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-16x16.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-16x16.png new file mode 100644 index 0000000..a504ecb Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-16x16.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-32x32.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-32x32.png new file mode 100644 index 0000000..d4cf851 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-32x32.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-48x48.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-48x48.png new file mode 100644 index 0000000..0bfba45 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon-48x48.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon.ico b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon.ico new file mode 100644 index 0000000..b6e550e Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/favicon.ico differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/html-snippet.txt b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/html-snippet.txt new file mode 100644 index 0000000..753dafb --- /dev/null +++ b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/html-snippet.txt @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/mstile-150x150.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/mstile-150x150.png new file mode 100644 index 0000000..ac444a8 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/mstile-150x150.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/site.webmanifest b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/site.webmanifest new file mode 100644 index 0000000..04d459c --- /dev/null +++ b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/favicons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Vendoo", + "short_name": "Vendoo", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#FAF6F2", + "background_color": "#FAF6F2", + "display": "standalone" +} \ No newline at end of file diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-120x120.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-120x120.png new file mode 100644 index 0000000..286068f Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-120x120.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-128x128.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-128x128.png new file mode 100644 index 0000000..0e7eb35 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-128x128.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-144x144.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-144x144.png new file mode 100644 index 0000000..2e7a611 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-144x144.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-152x152.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-152x152.png new file mode 100644 index 0000000..ac444a8 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-152x152.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-167x167.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-167x167.png new file mode 100644 index 0000000..4fed7a6 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-167x167.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-16x16.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-16x16.png new file mode 100644 index 0000000..a504ecb Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-16x16.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-180x180.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-180x180.png new file mode 100644 index 0000000..8dac099 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-180x180.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-192x192.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-192x192.png new file mode 100644 index 0000000..d1f5496 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-192x192.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-256x256.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-256x256.png new file mode 100644 index 0000000..5e3158d Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-256x256.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-32x32.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-32x32.png new file mode 100644 index 0000000..d4cf851 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-32x32.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-384x384.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-384x384.png new file mode 100644 index 0000000..f858e18 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-384x384.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-48x48.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-48x48.png new file mode 100644 index 0000000..0bfba45 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-48x48.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-512x512.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-512x512.png new file mode 100644 index 0000000..c237302 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-512x512.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-64x64.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-64x64.png new file mode 100644 index 0000000..034f052 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-64x64.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-72x72.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-72x72.png new file mode 100644 index 0000000..61e7219 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-72x72.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-96x96.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-96x96.png new file mode 100644 index 0000000..75cf5d4 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-96x96.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-original-1254.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-original-1254.png new file mode 100644 index 0000000..44b3ace Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/icons/vendoo-icon-original-1254.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-original.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-original.png new file mode 100644 index 0000000..44ac64d Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-original.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-transparent.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-transparent.png new file mode 100644 index 0000000..2449e44 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/logo/vendoo-logo-transparent.png differ diff --git a/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/social/vendoo-brand-guidelines.png b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/social/vendoo-brand-guidelines.png new file mode 100644 index 0000000..02e05f6 Binary files /dev/null and b/MockupDesign/Vendoo_Logo_Icon_Favicon_Paket/social/vendoo-brand-guidelines.png differ diff --git a/MockupDesign/Vorlagen.png b/MockupDesign/Vorlagen.png new file mode 100644 index 0000000..2e93bb3 Binary files /dev/null and b/MockupDesign/Vorlagen.png differ diff --git a/README.md b/README.md index 0e6793c..dff9e1d 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,311 @@ -# Vendoo +# Vendoo 1.36.0 -Vendoo ist ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. +Vendoo 1.36.0 führt den ersten vollständig nativen Fachbereich auf dem Plattformkernel ein: den **Secure Theme Manager**. Benutzer können ihre persönliche Darstellung über geprüfte Eingabemasken wählen; Administratoren verwalten sichere Theme-Profile und den Systemstandard. Freies CSS, JavaScript, URLs oder Dateipfade werden nicht akzeptiert. -## Status +## Theme Manager 1.36.0 -- Version: `1.0.0-baseline` -- Hauptbranch: `main` -- Entwicklungsbranch: `develop` -- UI-Sprache: Deutsch -- Repository: privat +- Light, Dark, System und High Contrast +- persönliche Theme-Auswahl pro Benutzer +- UI-Dichte und reduzierte Bewegung +- eigene Theme-Profile auf Basis eines geprüften Basisthemes +- Live-Vorschau ohne sofortige Speicherung +- serverseitige Token- und Kontrastprüfung +- Import und Export validierter Theme-Profile +- administrativer Systemstandard +- Audit-Protokoll für Änderungen +- 42 Design Tokens einschließlich Komponenten-, Fokus- und Zielgrößen-Tokens -Die vollständige System- und Feature-Dokumentation befindet sich in [`FEATURES.md`](FEATURES.md). +Die Theme-Verwaltung erfolgt vollständig über Vendoo. Es ist keine manuelle Bearbeitung von CSS-, JSON- oder Konfigurationsdateien erforderlich. -## Tech Stack +## Plattformarchitektur ab 1.35.0 -- Node.js + Express als ESM -- Vanilla HTML, CSS und JavaScript ohne Framework oder Build-Schritt -- SQLite über `better-sqlite3` -- CSS Custom Properties für Theming und Dark Mode -- Session-basierte Authentifizierung mit Rollen `admin` und `editor` +- Kernel: `app/kernel/` +- Modulmanifeste: `app/modules/*/module.json` +- Architekturgrenzen: `app/architecture-boundaries.json` +- Design-Token-Quelle: `public/design-system/tokens/source.json` +- Theme-Vertrag: `public/design-system/theme-contract.json` +- Architektur-Gate: `npm run verify:architecture` +- vollständige technische Dokumentation: `docs/architecture/` -## Projektregeln +Die zwölf registrierten Bestandsmodule stehen bewusst auf `legacy-bridge`. Das zeigt transparent, welche Bereiche bereits einen Vertrag besitzen, aber noch schrittweise aus `server.mjs`, `public/app.js` und `public/style.css` herausgelöst werden müssen. -- Neue Datenbankspalten werden als Migration im `MIGRATIONS`-Array von `lib/db.mjs` ergänzt. -- Neue API-Routen werden in `server.mjs` angelegt. -- Neue Datenbankfunktionen werden aus `lib/db.mjs` exportiert und in `server.mjs` importiert. -- Frontend-Tabs verwenden `data-tab`, `
                                              ` und die `TAB_TITLES`-Map. -- Farben werden ausschließlich über CSS-Variablen definiert; Dark Mode liegt unter `[data-theme="dark"]`. -- SKU-Format: `VD-XXXX`. -- Schreibende Requests benötigen Authentifizierung, Rollenprüfung und CSRF-Schutz. +## Einfachster Start -## Betriebsmodelle - -1. Lokale Windows-Installation -2. Privater Docker-Betrieb für einen geschlossenen Benutzerkreis -3. Öffentlicher Docker-Betrieb auf einem getrennten Server/VPS - -Ein direktes Freigeben des NAS oder von Port `8124` ins Internet ist nicht vorgesehen. +- **Windows lokal oder Docker Desktop:** `setup.bat` doppelklicken, Ziel und Netzwerkzugriff auswählen, **Installieren** anklicken. +- **Linux/NAS/VPS:** `chmod +x setup.sh && ./setup.sh`, danach den geführten Assistenten verwenden. +- **VPS:** Domain eingeben; der integrierte Caddy-Stack übernimmt HTTPS automatisch, sobald DNS sowie Ports 80/443 korrekt auf den Server zeigen. +- **GitHub-Erstimport:** `GitHub-Deploy-Assistent.bat` starten. Der Assistent prüft Secrets/Laufzeitdaten, erstellt einen Release-Branch und öffnet die Browser-Anmeldung für Git. ## Sicherheit -Keine `.env`-Dateien, API-Schlüssel, OAuth-Tokens, SQLite-Datenbanken, Uploads, Backups oder Logs committen. Weitere Hinweise stehen in [`SECURITY.md`](SECURITY.md). +- `.env`, Datenbanken, Uploads, Backups, Logs, Operations-Laufzeitdaten und FLUX-/ComfyUI-Modelle werden niemals committed. +- Der Erst-Admin-Code wird zufällig erzeugt und auf Windows in die Zwischenablage kopiert. +- Öffentliche VPS-Installationen verwenden ausschließlich HTTPS und veröffentlichen Vendoo nicht direkt auf Port 8124. +- Updates und Betriebsartwechsel löschen keine persistenten Daten. -## Lizenz +--- + +## Vorheriger Stand 1.31.0 + +Vendoo 1.31.0 schafft die Grundlage für einen kontrollierten Mehrbenutzer- und Serverbetrieb. Rollen und Berechtigungen werden nicht nur in der Oberfläche, sondern an jeder geschützten API erzwungen. Artikelbearbeitung wird mit kurzlebigen Sperren koordiniert; Sitzungen, fehlgeschlagene Logins, Audit-Ereignisse und Rate Limits sind nachvollziehbar. + +## Rollen + +- **Administrator:** vollständiger Zugriff einschließlich Benutzer, Backups, Updates und Sicherheit. +- **Manager:** operative Verwaltung von Artikeln, Medien und Publishing, ohne Benutzer- oder Systemadministration. +- **Editor:** Artikel, Bilder, AI, FLUX, Bildproduktion und Qualitätsprüfung. +- **Publisher:** Artikel lesen, Qualität prüfen und Veröffentlichungen ausführen. +- **Leser:** reiner Lesezugriff. + +## Sicherer Netzwerkbetrieb + +Standardmäßig bindet Vendoo ausschließlich an `127.0.0.1`. Einen privaten LAN-Zugriff aktiviert der Installationsassistent nur nach bewusster Auswahl und setzt die erforderlichen Werte automatisch. Für öffentlichen Zugriff verwendet der VPS-Assistent den getrennten Caddy-Stack mit HTTPS; Port 8124 wird dabei nicht direkt veröffentlicht. + +## Bestehende Funktionen + +Alle Funktionen aus 1.30.0 bleiben enthalten: Operations Center, Publishing Hardening, Quality Center, Batch Image Factory, FLUX Studio Pro, Advanced Image Editor, Galerie und Browser-Extensions. + +## Betriebszentrale (1.30.0) + +Unter **Admin → System** stehen jetzt drei zusammenhängende Bereiche bereit: + +- **Backup & Restore** mit SQLite-Snapshot, Datei-Prüfsummen, Integritätsprüfung, Download, Rotation und Wiederherstellungs-Staging +- **Update Center** mit Versionsprüfung, Paketvalidierung, Migrationsstatus, Sicherheitsbackup und bewusstem Offline-Anwenden über `setup.bat` +- **Extension-Diagnose** mit Paketversionen, Heartbeats, zuletzt verbundenen Browsern und Live-/Offline-Status + +Update und Wiederherstellung ersetzen niemals während des laufenden Servers Dateien. Nach erfolgreicher Prüfung wird eine Operation vorbereitet und anschließend bei beendetem Vendoo über Setup-Menüpunkt 11 angewendet. Menüpunkt 12 stellt den zuvor gesicherten Programm- und Datenstand wieder her. + +## Sicherheitsprinzipien + +- Keine direkte Kopie einer geöffneten SQLite-Datei; Backups verwenden die SQLite-Backup-API. +- Jedes Backup enthält ein Manifest mit SHA-256-Prüfsummen und wird sofort geprüft. +- Eine Wiederherstellung erzeugt vorab automatisch ein vollständiges Sicherheitsbackup. +- Update-ZIPs mit `.env`, Datenbank, Uploads oder `node_modules` werden abgelehnt. +- Zugangsdaten aus `.env` werden nur nach ausdrücklicher Auswahl in manuelle Backups aufgenommen. +- Automatische Backups enthalten niemals `.env`. +- Update und Restore besitzen einen lokalen Rollback-Snapshot. + +## Publishing Hardening (1.29.0) + +- persistente Publishing-Queue für API- und Extension-Aufträge +- Schutz vor Doppelveröffentlichungen +- automatische Wiederholungen mit wachsendem Abstand +- Abbruch, manueller Retry und sichere Protokolllöschung +- Fehlerzentrale mit Audit-Ereignissen +- externe IDs, URLs und eBay Offer-IDs +- eBay Statusabgleich, Aktualisierung und Beenden über offizielle API +- Etsy Statusabgleich über offizielle API +- Vinted/Kleinanzeigen als bestätigungspflichtiger Extension-Workflow +- Wiederaufnahme laufender API-Aufträge nach Vendoo-Neustart +- geplante Veröffentlichungen werden an dieselbe gehärtete Queue übergeben + +Vendoo 1.28.0 ergänzt die produktive **Batch Image Factory**. Mehrere Bilder aus der zentralen Galerie können in einem persistenten Auftrag über ein oder mehrere Produktionsprofile für Marktplätze, Webshops und Social Media verarbeitet werden. Ergebnisse bleiben nicht destruktiv, erscheinen wieder in der Galerie und lassen sich gesammelt als ZIP exportieren. + +## Batch Image Factory (1.28.0) + +- Hauptmenü **Bildproduktion** mit Quellenwahl aus der zentralen Bildergalerie +- Acht integrierte Ausgabeprofile sowie eigene speicherbare Profile +- Mehrfachprofile pro Auftrag und bis zu 200 Quellbilder +- Hintergrundentfernung, Sensitivität und Wasserzeichen als globale Auftragsoptionen +- persistente Job- und Positionshistorie mit Pause, Fortsetzen, Abbruch und Retry +- Wiederaufnahme nach Vendoo-Neustart +- nicht destruktive Ergebnisse unter `uploads/batch/` +- automatische Galerieintegration und ZIP-Export pro Auftrag + + +## Neu in 1.12.0 + +Der mobile QR-Upload bietet nun zwei eindeutige Wege: **Kamera öffnen** und **Mediathek auswählen**. Dadurch erzwingt das Smartphone nicht mehr ausschließlich die Kamera. Mehrere vorhandene Bilder können direkt gewählt werden; HEIC/HEIF werden nach Möglichkeit serverseitig zu JPG konvertiert. + +OpenRouter wurde zusätzlich gehärtet: Vendoo erkennt Free-Bildmodelle präziser, prüft Bildinput-Fähigkeit, versucht mehrere kompatible Request-Formen und wechselt bei Fehlern automatisch zum nächsten Modell. Sind aktuell keine kostenlosen Bildmodelle vorhanden, zeigt die Diagnose das ausdrücklich an. + +## Neu in 1.11.0 + +Der QR-Handy-Upload erzeugt jetzt automatisch Links mit der erkannten LAN-IP des Vendoo-PCs. Unter **Einstellungen → Vendoo LAN-Adresse für QR-Upload** kann die Adresse erkannt, geprüft und dauerhaft gespeichert werden. Die mobile Navigation wurde für Smartphone und Tablet komplett stabilisiert. Bei OpenRouter-Bildgenerierung versucht Vendoo automatisch mehrere passende Free-Modelle nacheinander und nutzt im Auto-Modus anschließend OpenAI als Fallback. + +## Neu in 1.10.0 + +Vendoo unterstützt jetzt eine **AI-Job-Queue** für Bildgenerierung. Kleidung kann mit **1 bis 3** Varianten als **Model**, **Ghost Mannequin** oder **Flatlay** generiert werden. Im Listing-Editor gibt es zusätzlich eine **Historie früherer AI-Generierungen**, damit erzeugte Bilder schnell wiederverwendet werden können. + +## Neu in 1.9.0 + +Die Einstellungen wurden um ein eigenes Steuerzentrum für **AI-Model-Fotos** erweitert. Dort lassen sich Provider, Free-Filter, Safety-Blocker, Standard-Preset, Variantenanzahl und das OpenAI-Fallback-Modell pflegen. Zusätzlich kann die UI die aktuell verfügbaren OpenRouter-Bildmodelle direkt laden und anzeigen. + +## Neu in 1.8.0 + +Vendoo kann jetzt neben OpenAI auch OpenRouter für AI-Model-Fotos verwenden. Wenn `ai_model_photo_provider=auto` gesetzt ist und `OPENROUTER_API_KEY` vorhanden ist, fragt Vendoo automatisch aktuelle Bildmodelle von OpenRouter ab, filtert auf Bildausgabe und – sofern aktiviert – nur kostenlose Varianten. Gibt es kein passendes freies Modell mehr, fällt Vendoo sauber auf OpenAI zurück. + +### Neue relevante Settings + +- `ai_model_photo_provider`: `auto`, `openrouter`, `openai` +- `ai_model_photo_openrouter_free_only`: `1` oder `0` +- `ai_model_photo_openrouter_model`: optional feste OpenRouter-Model-ID +- `ai_model_photo_openai_model`: OpenAI-Fallback-Modell, Default `gpt-image-1` + +### Neue ENV-Variablen + +- `OPENROUTER_API_KEY` +- optional `OPENROUTER_SITE_URL` +- optional `OPENROUTER_APP_NAME` + +## Neu in 1.7.0 + +Vendoo kann jetzt für geeignete Kleidungsstücke sichere AI-Model-Fotos erzeugen. Der Workflow prüft zuerst, ob ein Kleidungsartikel ohne Person vorliegt, blockiert sensible Kategorien und erstellt danach 1–3 zusätzliche Lifestyle-/Studio-Bilder mit fiktiven erwachsenen Personen oder alternative Ghost-Mannequin-/Flatlay-Varianten. Die Bilder lassen sich direkt in Generator und Listing-Editor übernehmen. + + +## Neu in 1.26.0 + +- Hauptnavigation mit **Neuer Artikel** direkt unter dem Dashboard und **Artikel** statt Listings +- repariertes Publish Center mit zuverlässigem Smart-Copy-Popup +- zoombare Medienvorschau mit Fit, 100 %, Mausrad und Verschieben +- kompakte lokale SVG-Iconbibliothek für Galerieaktionen +- aktualisierte Verkäufergebühren für eBay, Vinted, Kleinanzeigen und Etsy +- eigenes Extension Center für Chrome, Edge, Firefox und Safari + +# Vendoo + +Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System. + +## Aktueller Entwicklungsstand + +- Version: `1.36.0` +- UI-Slice: `UI 2026 / Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload` +- Startseite: `Studio Flow` +- Tech Stack: Node.js, Express (ESM), Vanilla JS, SQLite (`better-sqlite3`) + +Die vollständige Funktionsbeschreibung steht in [`FEATURES.md`](FEATURES.md). Die freigegebene Designrichtung liegt unter [`MockupDesign/`](MockupDesign/). Der vollständige Listing-Editor ist in [`docs/FULL_LISTING_EDITOR_2026.md`](docs/FULL_LISTING_EDITOR_2026.md) dokumentiert. + +## Lokaler Start + +```bash +npm install +npm start +``` + +Danach ist Vendoo standardmäßig unter `http://localhost:8124` erreichbar. + +## Wichtige Sicherheitsregel + +Nicht versionieren oder weitergeben: + +- `.env` +- `db/vendoo.db*` +- `uploads/` +- Backups und Logs +- API-Schlüssel, OAuth-Tokens und SMTP-Passwörter + +Die bereitgestellte Projektversion enthält ausschließlich `.env.example` und keine produktiven Laufzeitdaten. + +## Browser-Erweiterungen + +Die aktuellen Erweiterungen liegen getrennt unter `extensions/chrome`, `extensions/edge`, `extensions/firefox` und `extensions/safari`. Über den Vendoo-Menüpunkt **Extensions** wird der aktive Browser erkannt, der Installationsordner geöffnet und der Pfad kopiert. Safari benötigt auf macOS zusätzlich Xcode und die mitgelieferte WebExtension-Konvertierung. + +## Mobiler Foto-Upload + +Im Generator und im Listing-Editor kann ein zeitlich begrenzter QR-Code erzeugt werden. Nach dem Scan können Fotos direkt vom Smartphone aufgenommen und ohne Neuladen an Vendoo übertragen werden. Für andere Geräte muss Vendoo über eine erreichbare LAN-IP oder `PUBLIC_BASE_URL` geöffnet sein; `localhost` funktioniert nur auf demselben Gerät. + +## Browser-Erweiterung 1.4.0 + +Die Erweiterungen für Chrome, Edge und Firefox verwenden jetzt eine kompakte, aktionsorientierte Oberfläche. Die primäre Aktion **Nur ausfüllen** trägt ein Listing in das geöffnete Vinted-Formular ein, ohne einen Entwurf zu speichern und ohne eine Veröffentlichung auszulösen. Abschlussaktionen sind davon deutlich getrennt. + + +## Aktueller UI-Stand + +Slice 10 ergänzt das interaktive Dashboard, den aufgeräumten Studio Flow, robuste Publish-Initialisierung, ZIP-Fotodownloads und erweiterte Responsive-Gates. + + +## Branding + +Logo, App-Icons, mobile Größen und Favicons liegen unter `public/brand/`. Die Browser-Erweiterungen für Chrome, Edge und Firefox verwenden dieselbe Icon-Familie. + + +## Local FLUX / ComfyUI +Vendoo can now prefer a local image backend for AI model photos. The intended order is: **Local FLUX / ComfyUI -> OpenRouter Free -> OpenAI**. + +### What was added +- Settings for local URL, token, workflow path and install path +- Backend-only access to the local image server +- PowerShell bootstrap via `setup.ps1` or Settings UI +- Token regeneration for restricted local access + +### Default assumptions +- ComfyUI is reachable at `http://127.0.0.1:8188` +- Workflow file: `local-ai/workflows/vendoo_flux_schnell_api.json` +- Vendoo talks to the local server from the backend, not directly from the browser + +> Note: the included workflow template is a bootstrap starter and may need adjustment to the exact ComfyUI / FLUX node setup on the target machine. + + +## Vollautomatische lokale FLUX-Installation + +Vendoo kann ComfyUI Portable und optional FLUX.1-schnell FP8 direkt aus der Einstellungsseite oder über `setup.bat` installieren. + +### Über die Benutzeroberfläche + +1. **Einstellungen → AI-Model-Fotos** öffnen. +2. Als Provider `Auto` oder `Nur lokales FLUX / ComfyUI` auswählen. +3. Installationsoptionen wählen. +4. **Lokales FLUX installieren** anklicken. +5. Den Fortschritt direkt in Vendoo verfolgen. + +### Über setup.bat + +- `7` – lokales FLUX / ComfyUI installieren oder aktualisieren +- `8` – Status anzeigen +- `9` – lokalen Server starten +- `10` – lokalen Server stoppen + +### Sicherheitsmodell + +ComfyUI wird ausschließlich an `127.0.0.1:8188` gebunden. Der Browser greift nicht direkt auf ComfyUI zu; nur das Vendoo-Backend sendet Bildjobs. Standardmäßig werden Remote-URLs abgelehnt. Ein zufälliger lokaler Token wird zusätzlich in der `.env` gespeichert. Das schützt den Dienst vor Zugriff aus dem LAN, ersetzt aber keine vollständige Prozessisolation gegenüber anderer Software auf demselben Windows-PC. + +### Große Downloads + +Der Modell-Download ist optional und wird vor dem Start ausdrücklich bestätigt. Bereits vorhandene ComfyUI- oder Modelldateien werden nicht ohne `Force` überschrieben. Downloads und Installationsfortschritt werden protokolliert. + + +## FLUX installer status and recovery +The local FLUX installer now records a detailed, resumable state in `local-ai/install-state.json`. The settings page displays individual checks for 7-Zip, the ComfyUI archive, embedded Python, ComfyUI, the FLUX model, workflow and runtime scripts. Failed installations can be started again without blindly discarding complete downloads. + +The PowerShell 5.1 path bug that could turn `C:\Program Files\7-Zip\7z.exe` into the command `C` has been fixed. + + +## FLUX installer status hotfix (1.14.2) +The local FLUX installer now supervises 7-Zip instead of waiting without feedback. During extraction Vendoo shows files, extracted MB, elapsed time, heartbeat and the current 7-Zip log. A stale installer is marked as **stalled** and can be resumed without downloading the complete ComfyUI archive again. + +## FLUX Studio ab 1.21.0 + +Vendoo enthält einen eigenen Hauptmenüpunkt **FLUX Studio**. Dort werden Bilder ausschließlich aus freien Prompts über das lokale ComfyUI/FLUX erzeugt. Einstellbar sind Stil, Ausgabeformat, Seed und Inferenzschritte. Ergebnisse werden in einer lokalen Bibliothek gespeichert, können heruntergeladen, in den Listing-Generator übernommen oder mit dem integrierten Editor nachbearbeitet werden. + +Der frühere FASHN-/Virtual-Try-on-Stack wurde vollständig aufgegeben. Es werden kein Python-, Torch-, ONNX- oder separater VTO-Dienst mehr installiert. Beim ersten Start des aktualisierten Setups werden alte VTO-Runtime, Autostarts und Einstellungen entfernt; eigene frühere Modelbilder werden zur Sicherheit nach `uploads/ai-generated/legacy-models` verschoben. + +### Update +1. Vendoo schließen. +2. Paketinhalt über die bestehende Installation kopieren und Dateien ersetzen. +3. `setup.bat` starten und **Reparieren** ausführen. +4. Vendoo starten und den Menüpunkt **FLUX Studio** öffnen. +## FLUX Studio Pro ab 1.22.0 + +Slice 30 erweitert das reine Prompt-Studio zu einem persistenten Job-Center. Aufträge können 1, 2 oder 4 Varianten mit eigenen Seeds erzeugen. Seed Lock, Wiederholen, „Ähnlich erneut erzeugen“, Favoriten, Queue-/History-Anbindung, System-/GPU-Daten und Wiederaufnahme nach Vendoo-Neustart sind integriert. + +Die Fortschrittsanzeige verwendet im FLUX-Studio-Pro-Pfad ausschließlich reale Phasen und verstrichene Laufzeit. Es werden keine Prozentwerte aus einer angenommenen Renderdauer errechnet. + +### Update auf 1.22.0 + +1. Vendoo und ComfyUI-Fenster schließen. +2. Den Inhalt des Paket-Unterordners `vendoo` über die bestehende Installation kopieren und Dateien ersetzen. +3. Den bestehenden Zielordner und Laufzeitdaten nicht löschen. +4. `setup.bat` starten und **3 – Update** ausführen. +5. Bei Bedarf **9 – Lokales FLUX / ComfyUI starten**, danach **8 – Status** prüfen. +6. Vendoo starten und FLUX Studio öffnen. + + + +## Artikel Quality Center + +Vendoo 1.28.0 prüft Artikel vor dem Publishing auf Blocker, Vollständigkeit, Bildqualität, mögliche Duplikate und Plattformtauglichkeit. AI-Verbesserungen werden nur als Vorschlag angezeigt und erst nach ausdrücklicher Bestätigung gespeichert. + +## Installationsziel wählen (1.36.0) + +`setup.bat` führt auf Windows durch lokale Node-Installation oder Docker Desktop. Für Linux, NAS, Portainer und VPS steht `setup.sh` bereit. Beide Wege verwenden dieselbe `.env`, dieselben Compose-Dateien und dieselben persistenten Datenbereiche. -Privates, proprietäres Projekt. Alle Rechte vorbehalten. diff --git a/SECURITY.md b/SECURITY.md index dc983e0..c00fc0f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,34 +1,81 @@ -# Sicherheitsrichtlinie +# Sicherheit -Vendoo ist derzeit ein privates Projekt. Sicherheitsprobleme dürfen nicht als öffentliche GitHub-Issues mit Zugangsdaten, Tokens oder personenbezogenen Daten gemeldet werden. +## Vertrauliche Dateien -## Niemals committen +Folgende Dateien dürfen niemals in Git oder öffentliche Pakete gelangen: -- `.env` und produktive Konfigurationsdateien -- API-Schlüssel und OAuth-Tokens -- SMTP-Zugangsdaten -- Session-Secrets -- SQLite-Datenbanken einschließlich WAL- und SHM-Dateien -- Uploads, Backups und Logs -- private Zertifikate und Schlüssel +- `.env` +- `db/*.db`, `db/*.db-wal`, `db/*.db-shm` +- `uploads/` +- Backups, Logs und Session-Daten +- private Schlüssel und Zertifikate -## Mindestanforderungen für Online-Betrieb +## Online-Betrieb -- kein direktes Port-Forwarding auf Vendoo oder die NAS-Verwaltung -- HTTPS über vorgeschalteten Reverse Proxy oder sicheren Tunnel -- sichere Cookies (`HttpOnly`, `Secure`, `SameSite`) -- korrekt begrenztes Express `trust proxy` -- CSRF-Schutz für alle schreibenden Requests -- Rollen- und Organisationsprüfung auf jeder geschützten Ressource -- Rate-Limits für Login, Magic Links und sensible APIs -- nicht privilegierter Container ohne Docker-Socket -- persistente Daten ausschließlich in expliziten Vendoo-Volumes -- automatische, geprüfte Backups +Vendoo darf nicht durch eine direkte Portfreigabe von Port 8124 veröffentlicht werden. Für öffentliche Nutzer ist eine getrennte Server-/VPS-Instanz hinter HTTPS und Reverse Proxy vorgesehen. -## Geheimnisse +## Meldung von Sicherheitsproblemen -Produktive Geheimnisse werden über Server-Secrets beziehungsweise `_FILE`-Umgebungsvariablen bereitgestellt und nicht in der Datenbank oder im Repository im Klartext abgelegt. +Sicherheitsprobleme nicht mit Zugangsdaten oder personenbezogenen Daten in öffentliche Issues schreiben. -## Abhängigkeiten -Vor Releases werden Abhängigkeiten, Container-Basisimages und bekannte Sicherheitslücken geprüft. Sicherheitsupdates erhalten Vorrang vor neuen Features. +## Lokaler FLUX / ComfyUI + +- ComfyUI wird standardmäßig nur an `127.0.0.1` gebunden. +- Vendoo blockiert Remote-ComfyUI-URLs, solange `LOCAL_IMAGE_ALLOW_REMOTE` nicht ausdrücklich aktiviert wird. +- Die ComfyUI-Oberfläche wird nicht über das LAN veröffentlicht. +- API-Aufrufe erfolgen serverseitig durch Vendoo. +- Ein zufälliger Token wird für die lokale Integrationskonfiguration erzeugt. +- Andere Prozesse auf demselben Windows-Rechner können theoretisch ebenfalls auf einen localhost-Dienst zugreifen; für echte Prozessisolation wäre ein separates Windows-Konto oder eine VM erforderlich. + +## FLUX Studio + +- ComfyUI bleibt standardmäßig ausschließlich an `127.0.0.1` gebunden. +- Das FLUX Studio akzeptiert nur Textprompts und erzeugt Dateien unter `uploads/ai-generated`. +- API-Pfade für Bibliothek und Bearbeitungen werden serverseitig auf dieses Verzeichnis begrenzt. +- Start und Stopp der lokalen Engine erfordern eine Admin-Rolle. +- Bearbeitungen erzeugen neue Dateien; Originalausgaben werden nicht still überschrieben. + + +## Operations Center, Backups und Updates + +- Backups werden mit einem Manifest und SHA-256-Prüfsummen erzeugt und vor der Freigabe verifiziert. +- Sicherungen, die `.env` enthalten, enthalten möglicherweise API-Schlüssel, Tokens und Zugangsdaten. Sie sind wie Passwörter zu behandeln und dürfen nicht unverschlüsselt weitergegeben werden. +- Restore- und Update-Archive werden vor der Anwendung in einem lokalen Staging-Bereich geprüft. +- Archive mit Pfad-Traversal, absoluten Pfaden oder unzulässigen Laufzeitdaten werden abgewiesen. +- Updates dürfen keine `.env`, produktive Datenbank, Uploads, Backups, Logs, Modelle oder `node_modules` mitbringen. +- Das eigentliche Anwenden erfolgt nur bei gestopptem Vendoo über `setup.bat` und erzeugt vorher einen Rollback-Snapshot. +- Eine optionale Update-Manifest-URL sollte ausschließlich über HTTPS und aus einer kontrollierten Quelle verwendet werden. +- Extension-Heartbeats enthalten nur Diagnosemetadaten wie Browser, Version, Plattform und Zeitpunkt; keine Formularinhalte oder Zugangsdaten. +## Mehrbenutzer- und Server-Sicherheit ab 1.31.0 + +- Vendoo bindet standardmäßig an `127.0.0.1`. LAN-Zugriff muss ausdrücklich über `VENDOO_BIND_HOST=0.0.0.0` aktiviert werden. +- Öffentlicher Betrieb ausschließlich über HTTPS und einen korrekt konfigurierten Reverse Proxy. `VENDOO_TRUST_PROXY=true` nur setzen, wenn der Proxy vertrauenswürdig ist und Client-IP-Header kontrolliert. +- `VENDOO_ALLOWED_HOSTS` und `VENDOO_ALLOWED_ORIGINS` eng begrenzen. +- Rollen ersetzen keine Betriebssystemrechte: Datenbank, `.env`, Backups und Uploads müssen auf Dateisystemebene geschützt bleiben. +- Bearbeitungssperren verhindern parallele Änderungen, ersetzen aber keine Backups. +- Audit-Logs können personenbezogene Betriebsdaten wie Benutzer, IP und Aktionen enthalten; Aufbewahrung und Zugriff müssen organisatorisch geregelt werden. + + + +## Architektur-Sicherheitsregeln ab 1.35.0 + +- Neue Kernel-Routen sind Deny-by-default und benötigen eine registrierte Policy. +- Modulmanifeste, Abhängigkeiten, Capabilities und Ownership werden vor dem Start validiert. +- Zyklische Abhängigkeiten, fehlende Capabilities und doppelte Ressourcen-Ownership blockieren den Kernelstart. +- Drittanbieter-Erweiterungen dürfen nicht im Vendoo-Hauptprozess ausgeführt werden. +- Themes dürfen nur freigegebene, typisierte Design Tokens verändern; freies CSS, JavaScript und externe URLs sind verboten. +- Der Event Bus ordnet Listener einem Owner zu, damit Module beim Stoppen ihre Listener vollständig entfernen können. +- Das Architektur-Gate `npm run verify:architecture` ist verpflichtender Bestandteil von CI und Releases. + +## Theme-Sicherheit ab 1.36.0 + +- Theme-Profile akzeptieren ausschließlich bekannte, typisierte Design Tokens. +- Freies CSS, JavaScript, HTML, URLs und Dateipfade sind nicht zulässig. +- Alle Werte werden im Browser zur Benutzerführung und erneut auf dem Server validiert. +- Normale Benutzer können nur die eigene Theme-Präferenz verändern. +- Theme-Profile und Systemstandard erfordern `settings.manage`. +- Theme-Änderungen werden auditiert. +- Importierte Profile durchlaufen dieselbe Validierung wie manuell erstellte Profile. +- Kontrastprüfungen verhindern nicht automatisch jede bewusste administrative Gestaltung, zeigen problematische Paare aber vor dem Speichern deutlich an. + diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..8219ed4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Drittanbieter-Hinweise für lokale AI-Engines + +## FLUX / ComfyUI + +Vendoo kann eine lokale ComfyUI-Installation mit einem FLUX-kompatiblen Modell ansteuern. ComfyUI, Modelle, Custom Nodes und deren Gewichte werden nicht als Vendoo-eigene Software ausgegeben. Für jede installierte Komponente gelten die jeweiligen Lizenz- und Nutzungsbedingungen der Herausgeber. + +Vendoo liefert keine FASHN-VTON-, Python-, Torch-, ONNX- oder Human-Parser-Komponenten mehr aus und installiert diese auch nicht mehr. + + +## Lucide Icons + +Die lokale Vendoo-Iconbibliothek verwendet eine kompakte Auswahl von Lucide-Icon-Geometrien. +Lucide steht unter der ISC-Lizenz. Projekt: https://lucide.dev/ diff --git a/app/architecture-boundaries.json b/app/architecture-boundaries.json new file mode 100644 index 0000000..0c928a5 --- /dev/null +++ b/app/architecture-boundaries.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "architecture": "modular-monolith", + "rules": { + "modulePrefix": "vendoo.", + "denyImplicitRoutes": true, + "denyCrossModuleDatabaseAccess": true, + "denyCrossModuleInternalImports": true, + "thirdPartyExtensionsInMainProcess": false, + "themeCustomCss": false, + "themeTokensOnly": true + }, + "migration": { + "mode": "strangler", + "legacyBridgeStatus": "legacy-bridge", + "frameworkMigration": false, + "databaseReplacement": false + } +} diff --git a/app/contracts/module.schema.json b/app/contracts/module.schema.json new file mode 100644 index 0000000..737ac22 --- /dev/null +++ b/app/contracts/module.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vendoo.local/contracts/module.schema.json", + "title": "Vendoo Module Manifest", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"], + "properties": { + "schemaVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, + "type": { "enum": ["core", "feature", "adapter", "extension-host"] }, + "status": { "enum": ["native", "legacy-bridge", "disabled"] }, + "description": { "type": "string", "maxLength": 500 }, + "requires": { "$ref": "#/$defs/moduleIds" }, + "permissions": { "$ref": "#/$defs/capabilities" }, + "provides": { "$ref": "#/$defs/capabilities" }, + "consumes": { "$ref": "#/$defs/capabilities" }, + "owns": { "$ref": "#/$defs/capabilities" } + }, + "$defs": { + "moduleIds": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" } + }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" } + } + } +} diff --git a/app/core/themes/theme-contract.mjs b/app/core/themes/theme-contract.mjs new file mode 100644 index 0000000..24e1e66 --- /dev/null +++ b/app/core/themes/theme-contract.mjs @@ -0,0 +1,169 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { APP_ROOT } from '../../../lib/runtime-paths.mjs'; +import { PlatformError } from '../../kernel/errors.mjs'; + +const source = JSON.parse(readFileSync(join(APP_ROOT, 'public', 'design-system', 'tokens', 'source.json'), 'utf8')); +const BUILTIN_THEME_IDS = new Set([...source.themes, 'system', 'inherit']); +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +const RGB_COLOR = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i; +const DIMENSION = /^-?\d+(?:\.\d+)?px$/i; +const THEME_ID = /^[a-z][a-z0-9-]{2,39}$/; + +function expandHex(value) { + const hex = value.replace('#', ''); + if (hex.length === 3 || hex.length === 4) return hex.split('').map(char => char + char).join(''); + return hex; +} + +function parseColor(value) { + const raw = String(value || '').trim(); + if (HEX_COLOR.test(raw)) { + const hex = expandHex(raw); + return [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16)); + } + const match = raw.match(RGB_COLOR); + if (!match) return null; + const channels = match.slice(1, 4).map(Number); + return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels : null; +} + +function validColor(value) { + if (!parseColor(value)) return false; + const match = String(value).trim().match(RGB_COLOR); + if (!match) return true; + const alpha = match[4] === undefined ? 1 : Number(match[4]); + return Number.isFinite(alpha) && alpha >= 0 && alpha <= 1; +} + +function validateValue(token, value) { + const raw = String(value ?? '').trim(); + if (token.type === 'color') return validColor(raw); + if (token.type === 'dimension') { + if (!DIMENSION.test(raw)) return false; + const numeric = Number.parseFloat(raw); + if (token.min !== undefined && numeric < Number(token.min)) return false; + if (token.max !== undefined && numeric > Number(token.max)) return false; + return true; + } + return false; +} + +function relativeLuminance(value) { + const rgb = parseColor(value); + if (!rgb) return null; + const linear = rgb.map(channel => { + const normalized = channel / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +export function contrastRatio(foreground, background) { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + if (first === null || second === null) return null; + const lighter = Math.max(first, second); + const darker = Math.min(first, second); + return (lighter + 0.05) / (darker + 0.05); +} + +export function getBaseThemeValues(themeId = 'light') { + const base = source.themes.includes(themeId) ? themeId : 'light'; + return Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, token[base]])); +} + +export function resolveThemeValues({ baseTheme = 'light', values = {} } = {}) { + return Object.freeze({ ...getBaseThemeValues(baseTheme), ...values }); +} + +export function evaluateThemeAccessibility({ baseTheme = 'light', values = {} } = {}) { + const resolved = resolveThemeValues({ baseTheme, values }); + const checks = [ + ['primary-on-canvas', 'color.ink', 'color.canvas', 4.5], + ['primary-on-surface', 'color.ink', 'color.surface', 4.5], + ['secondary-on-surface', 'color.inkSecondary', 'color.surface', 4.5], + ['muted-on-surface', 'color.inkMuted', 'color.surface', 3], + ['brand-on-surface', 'color.brand', 'color.surface', 3], + ['danger-on-surface', 'color.danger', 'color.surface', 3], + ['line-on-surface', 'color.lineStrong', 'color.surface', 3], + ].map(([id, foregroundToken, backgroundToken, minimum]) => { + const ratio = contrastRatio(resolved[foregroundToken], resolved[backgroundToken]); + return { + id, + foregroundToken, + backgroundToken, + ratio: ratio === null ? null : Number(ratio.toFixed(2)), + minimum, + ok: ratio !== null && ratio >= minimum, + }; + }); + return Object.freeze({ + ok: checks.every(check => check.ok), + checks: Object.freeze(checks), + failures: Object.freeze(checks.filter(check => !check.ok)), + }); +} + +export function getThemeContract() { + return { + schemaVersion: source.schemaVersion, + contractVersion: source.contractVersion, + themes: [...source.themes], + tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, { + css: token.css, + type: token.type, + customizable: Boolean(token.customizable), + label: token.label || name, + group: token.group || name.split('.')[0], + ...(token.min !== undefined ? { min: token.min } : {}), + ...(token.max !== undefined ? { max: token.max } : {}), + }])), + }; +} + +export function getBuiltinThemes() { + return source.themes.map(id => ({ + id, + name: ({ light: 'Hell', dark: 'Dunkel', contrast: 'Hoher Kontrast' })[id] || id, + base_theme: id, + builtin: true, + values: {}, + resolved_values: getBaseThemeValues(id), + accessibility: evaluateThemeAccessibility({ baseTheme: id }), + })); +} + +export function validateThemeDefinition(theme, { allowExistingId = false } = {}) { + if (!theme || typeof theme !== 'object' || Array.isArray(theme)) { + throw new PlatformError('Theme-Definition ist ungültig.', { code: 'THEME_INVALID', status: 400, expose: true }); + } + const id = String(theme.id || '').trim().toLowerCase(); + const name = String(theme.name || '').trim(); + const baseTheme = String(theme.base_theme || theme.baseTheme || 'light').trim().toLowerCase(); + if (!THEME_ID.test(id) || BUILTIN_THEME_IDS.has(id)) throw new PlatformError('Theme-ID ist ungültig oder reserviert.', { code: 'THEME_ID_INVALID', status: 400, expose: true }); + if (!allowExistingId && !id.startsWith('custom-')) throw new PlatformError('Eigene Theme-IDs müssen mit custom- beginnen.', { code: 'THEME_ID_PREFIX', status: 400, expose: true }); + if (!name || name.length > 80) throw new PlatformError('Theme-Name ist ungültig.', { code: 'THEME_NAME_INVALID', status: 400, expose: true }); + if (!source.themes.includes(baseTheme)) throw new PlatformError('Basis-Theme ist ungültig.', { code: 'THEME_BASE_INVALID', status: 400, expose: true }); + const values = {}; + for (const [tokenName, value] of Object.entries(theme.values || {})) { + const token = source.tokens[tokenName]; + if (!token || !token.customizable) throw new PlatformError(`Token ist nicht anpassbar: ${tokenName}`, { code: 'THEME_TOKEN_FORBIDDEN', status: 400, expose: true }); + if (!validateValue(token, value)) throw new PlatformError(`Ungültiger Wert für ${tokenName}.`, { code: 'THEME_VALUE_INVALID', status: 400, expose: true }); + values[tokenName] = String(value).trim(); + } + const accessibility = evaluateThemeAccessibility({ baseTheme, values }); + return Object.freeze({ schemaVersion: 1, id, name, base_theme: baseTheme, values: Object.freeze(values), accessibility }); +} + +export function validateThemePreference(input, { customThemeExists = () => false } = {}) { + const themeId = String(input?.theme_id || input?.themeId || 'inherit').trim().toLowerCase(); + const density = String(input?.density || 'comfortable').trim().toLowerCase(); + const reducedMotion = String(input?.reduced_motion || input?.reducedMotion || 'system').trim().toLowerCase(); + if (![...BUILTIN_THEME_IDS].includes(themeId) && !customThemeExists(themeId)) { + throw new PlatformError('Gewähltes Theme existiert nicht.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + } + if (!['compact', 'comfortable', 'spacious'].includes(density)) throw new PlatformError('UI-Dichte ist ungültig.', { code: 'THEME_DENSITY_INVALID', status: 400, expose: true }); + if (!['system', 'reduce', 'allow'].includes(reducedMotion)) throw new PlatformError('Bewegungseinstellung ist ungültig.', { code: 'THEME_MOTION_INVALID', status: 400, expose: true }); + return Object.freeze({ theme_id: themeId, density, reduced_motion: reducedMotion }); +} diff --git a/app/kernel/errors.mjs b/app/kernel/errors.mjs new file mode 100644 index 0000000..4c008d7 --- /dev/null +++ b/app/kernel/errors.mjs @@ -0,0 +1,31 @@ +export class PlatformError extends Error { + constructor(message, { code = 'PLATFORM_ERROR', status = 500, details = null, expose = false, cause = undefined } = {}) { + super(String(message || 'Unbekannter Plattformfehler'), { cause }); + this.name = 'PlatformError'; + this.code = String(code || 'PLATFORM_ERROR'); + this.status = Number.isInteger(status) ? status : 500; + this.details = details; + this.expose = Boolean(expose || this.status < 500); + } +} + +export function normalizePlatformError(error) { + if (error instanceof PlatformError) return error; + return new PlatformError(error?.message || 'Interner Serverfehler', { + code: error?.code || 'INTERNAL_ERROR', + status: Number.isInteger(error?.status) ? error.status : 500, + details: error?.details || null, + expose: Boolean(error?.expose), + cause: error, + }); +} + +export function platformErrorPayload(error, requestId = null) { + const normalized = normalizePlatformError(error); + return { + error: normalized.expose ? normalized.message : 'Interner Serverfehler', + code: normalized.code, + request_id: requestId || null, + ...(normalized.expose && normalized.details ? { details: normalized.details } : {}), + }; +} diff --git a/app/kernel/event-bus.mjs b/app/kernel/event-bus.mjs new file mode 100644 index 0000000..07964a1 --- /dev/null +++ b/app/kernel/event-bus.mjs @@ -0,0 +1,77 @@ +import { PlatformError } from './errors.mjs'; + +const EVENT_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class EventBus { + #listeners = new Map(); + #maxListeners; + + constructor({ maxListenersPerEvent = 50 } = {}) { + this.#maxListeners = Math.max(1, Number(maxListenersPerEvent) || 50); + } + + on(eventName, handler, { owner = 'core', once = false } = {}) { + if (!EVENT_PATTERN.test(String(eventName))) { + throw new PlatformError(`Ungültiger Eventname: ${eventName}`, { code: 'EVENT_NAME_INVALID', status: 500 }); + } + if (typeof handler !== 'function') { + throw new PlatformError('Event-Handler muss eine Funktion sein.', { code: 'EVENT_HANDLER_INVALID', status: 500 }); + } + const listeners = this.#listeners.get(eventName) || []; + if (listeners.length >= this.#maxListeners) { + throw new PlatformError(`Zu viele Listener für ${eventName}.`, { code: 'EVENT_LISTENER_LIMIT', status: 500 }); + } + const entry = Object.freeze({ handler, owner: String(owner || 'core'), once: Boolean(once) }); + listeners.push(entry); + this.#listeners.set(eventName, listeners); + return () => this.off(eventName, handler); + } + + once(eventName, handler, options = {}) { + return this.on(eventName, handler, { ...options, once: true }); + } + + off(eventName, handler) { + const listeners = this.#listeners.get(eventName) || []; + const remaining = listeners.filter(entry => entry.handler !== handler); + if (remaining.length) this.#listeners.set(eventName, remaining); + else this.#listeners.delete(eventName); + return remaining.length !== listeners.length; + } + + removeOwner(owner) { + let removed = 0; + for (const [eventName, listeners] of this.#listeners) { + const remaining = listeners.filter(entry => { + const match = entry.owner === owner; + if (match) removed += 1; + return !match; + }); + if (remaining.length) this.#listeners.set(eventName, remaining); + else this.#listeners.delete(eventName); + } + return removed; + } + + async emit(eventName, payload = null, context = {}) { + const listeners = [...(this.#listeners.get(eventName) || [])]; + const results = []; + for (const entry of listeners) { + try { + results.push({ owner: entry.owner, ok: true, value: await entry.handler(payload, Object.freeze({ ...context, eventName })) }); + } catch (error) { + results.push({ owner: entry.owner, ok: false, error }); + } finally { + if (entry.once) this.off(eventName, entry.handler); + } + } + return results; + } + + snapshot() { + return [...this.#listeners.entries()].map(([event, listeners]) => ({ + event, + listeners: listeners.map(entry => ({ owner: entry.owner, once: entry.once })), + })); + } +} diff --git a/app/kernel/feature-flags.mjs b/app/kernel/feature-flags.mjs new file mode 100644 index 0000000..a0ac014 --- /dev/null +++ b/app/kernel/feature-flags.mjs @@ -0,0 +1,59 @@ +import { PlatformError } from './errors.mjs'; + +const FLAG_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +function envKey(flag) { + return `VENDOO_FEATURE_${flag.replace(/[^a-z0-9]/gi, '_').toUpperCase()}`; +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + throw new PlatformError(`Ungültiger Feature-Flag-Wert: ${value}`, { code: 'FEATURE_FLAG_VALUE_INVALID', status: 500 }); +} + +export class FeatureFlagRegistry { + #flags = new Map(); + + define(name, { defaultValue = false, owner = 'vendoo.system', description = '', mutable = false } = {}) { + const normalized = String(name || ''); + if (!FLAG_PATTERN.test(normalized)) { + throw new PlatformError(`Ungültiger Feature-Flag: ${normalized}`, { code: 'FEATURE_FLAG_NAME_INVALID', status: 500 }); + } + if (this.#flags.has(normalized)) { + throw new PlatformError(`Feature-Flag bereits definiert: ${normalized}`, { code: 'FEATURE_FLAG_DUPLICATE', status: 500 }); + } + const configured = parseBoolean(process.env[envKey(normalized)], Boolean(defaultValue)); + this.#flags.set(normalized, { + name: normalized, + value: configured, + defaultValue: Boolean(defaultValue), + owner: String(owner), + description: String(description), + mutable: Boolean(mutable), + source: process.env[envKey(normalized)] === undefined ? 'default' : 'environment', + }); + return configured; + } + + isEnabled(name) { + if (!this.#flags.has(name)) { + throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 500 }); + } + return this.#flags.get(name).value; + } + + set(name, value) { + const flag = this.#flags.get(name); + if (!flag) throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 404, expose: true }); + if (!flag.mutable) throw new PlatformError(`Feature-Flag ist nicht zur Laufzeit änderbar: ${name}`, { code: 'FEATURE_FLAG_IMMUTABLE', status: 409, expose: true }); + flag.value = Boolean(value); + flag.source = 'runtime'; + return flag.value; + } + + list() { + return [...this.#flags.values()].map(flag => ({ ...flag })); + } +} diff --git a/app/kernel/lifecycle.mjs b/app/kernel/lifecycle.mjs new file mode 100644 index 0000000..85608e8 --- /dev/null +++ b/app/kernel/lifecycle.mjs @@ -0,0 +1,49 @@ +import { PlatformError } from './errors.mjs'; + +export class LifecycleManager { + #state = 'created'; + #hooks = []; + + register({ id, order = 100, start = async () => {}, stop = async () => {} }) { + if (!id || this.#hooks.some(hook => hook.id === id)) { + throw new PlatformError(`Ungültiger oder doppelter Lifecycle-Hook: ${id}`, { code: 'LIFECYCLE_HOOK_INVALID' }); + } + this.#hooks.push({ id: String(id), order: Number(order) || 100, start, stop, state: 'registered' }); + } + + async start(context) { + if (!['created', 'stopped'].includes(this.#state)) throw new PlatformError(`Kernel kann aus Zustand ${this.#state} nicht starten.`, { code: 'LIFECYCLE_STATE_INVALID' }); + this.#state = 'starting'; + const started = []; + try { + for (const hook of [...this.#hooks].sort((a, b) => a.order - b.order)) { + hook.state = 'starting'; + await hook.start(context); + hook.state = 'running'; + started.push(hook); + } + this.#state = 'running'; + } catch (error) { + for (const hook of started.reverse()) { + try { await hook.stop({ ...context, rollback: true }); } catch {} + hook.state = 'stopped'; + } + this.#state = 'failed'; + throw error; + } + } + + async stop(context) { + if (!['running', 'starting'].includes(this.#state)) return; + this.#state = 'stopping'; + for (const hook of [...this.#hooks].sort((a, b) => b.order - a.order)) { + if (hook.state !== 'running') continue; + try { await hook.stop(context); } finally { hook.state = 'stopped'; } + } + this.#state = 'stopped'; + } + + snapshot() { + return { state: this.#state, hooks: this.#hooks.map(({ start: _s, stop: _t, ...hook }) => ({ ...hook })) }; + } +} diff --git a/app/kernel/module-contract.mjs b/app/kernel/module-contract.mjs new file mode 100644 index 0000000..1b25b7f --- /dev/null +++ b/app/kernel/module-contract.mjs @@ -0,0 +1,47 @@ +import { PlatformError } from './errors.mjs'; + +const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/; +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const CAPABILITY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; +const TYPES = new Set(['core', 'feature', 'adapter', 'extension-host']); +const STATUSES = new Set(['native', 'legacy-bridge', 'disabled']); + +function uniqueStrings(values, label, pattern = CAPABILITY) { + if (!Array.isArray(values)) throw new PlatformError(`${label} muss ein Array sein.`, { code: 'MODULE_MANIFEST_INVALID' }); + const normalized = values.map(value => String(value || '').trim()); + if (normalized.some(value => !pattern.test(value))) { + throw new PlatformError(`${label} enthält einen ungültigen Eintrag.`, { code: 'MODULE_MANIFEST_INVALID' }); + } + if (new Set(normalized).size !== normalized.length) { + throw new PlatformError(`${label} enthält Duplikate.`, { code: 'MODULE_MANIFEST_INVALID' }); + } + return normalized; +} + +export function validateModuleManifest(manifest) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new PlatformError('Modulmanifest fehlt oder ist ungültig.', { code: 'MODULE_MANIFEST_INVALID' }); + } + const normalized = { + schemaVersion: Number(manifest.schemaVersion || 1), + id: String(manifest.id || '').trim(), + name: String(manifest.name || '').trim(), + version: String(manifest.version || '').trim(), + type: String(manifest.type || '').trim(), + status: String(manifest.status || 'native').trim(), + description: String(manifest.description || '').trim(), + requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)), + permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')), + provides: Object.freeze(uniqueStrings(manifest.provides || [], 'provides')), + consumes: Object.freeze(uniqueStrings(manifest.consumes || [], 'consumes')), + owns: Object.freeze(uniqueStrings(manifest.owns || [], 'owns')), + }; + if (normalized.schemaVersion !== 1) throw new PlatformError('Nicht unterstützte Manifestversion.', { code: 'MODULE_MANIFEST_VERSION' }); + if (!MODULE_ID.test(normalized.id)) throw new PlatformError(`Ungültige Modul-ID: ${normalized.id}`, { code: 'MODULE_ID_INVALID' }); + if (!normalized.name) throw new PlatformError(`Modulname fehlt: ${normalized.id}`, { code: 'MODULE_NAME_REQUIRED' }); + if (!SEMVER.test(normalized.version)) throw new PlatformError(`Ungültige Modulversion: ${normalized.id}`, { code: 'MODULE_VERSION_INVALID' }); + if (!TYPES.has(normalized.type)) throw new PlatformError(`Ungültiger Modultyp: ${normalized.id}`, { code: 'MODULE_TYPE_INVALID' }); + if (!STATUSES.has(normalized.status)) throw new PlatformError(`Ungültiger Modulstatus: ${normalized.id}`, { code: 'MODULE_STATUS_INVALID' }); + if (normalized.requires.includes(normalized.id)) throw new PlatformError(`Modul darf sich nicht selbst benötigen: ${normalized.id}`, { code: 'MODULE_SELF_DEPENDENCY' }); + return Object.freeze(normalized); +} diff --git a/app/kernel/module-registry.mjs b/app/kernel/module-registry.mjs new file mode 100644 index 0000000..5d162d5 --- /dev/null +++ b/app/kernel/module-registry.mjs @@ -0,0 +1,128 @@ +import { PlatformError } from './errors.mjs'; +import { validateModuleManifest } from './module-contract.mjs'; + +export class ModuleRegistry { + #modules = new Map(); + #startOrder = []; + #ownership = new Map(); + + register({ manifest, lifecycle = {} }) { + const validated = validateModuleManifest(manifest); + if (this.#modules.has(validated.id)) { + throw new PlatformError(`Modul bereits registriert: ${validated.id}`, { code: 'MODULE_DUPLICATE' }); + } + for (const resource of validated.owns) { + const owner = this.#ownership.get(resource); + if (owner) throw new PlatformError(`Ressource ${resource} wird bereits von ${owner} besessen.`, { code: 'MODULE_OWNERSHIP_CONFLICT' }); + } + const record = { + manifest: validated, + lifecycle: { + start: typeof lifecycle.start === 'function' ? lifecycle.start : async () => {}, + stop: typeof lifecycle.stop === 'function' ? lifecycle.stop : async () => {}, + health: typeof lifecycle.health === 'function' ? lifecycle.health : async () => ({ ok: true }), + }, + state: validated.status === 'disabled' ? 'disabled' : 'registered', + startedAt: null, + error: null, + }; + this.#modules.set(validated.id, record); + for (const resource of validated.owns) this.#ownership.set(resource, validated.id); + return validated; + } + + get(id) { + return this.#modules.get(id) || null; + } + + #resolveStartOrder() { + const visiting = new Set(); + const visited = new Set(); + const order = []; + const visit = id => { + if (visited.has(id)) return; + if (visiting.has(id)) throw new PlatformError(`Zyklische Modulabhängigkeit bei ${id}.`, { code: 'MODULE_DEPENDENCY_CYCLE' }); + const record = this.#modules.get(id); + if (!record) throw new PlatformError(`Fehlende Modulabhängigkeit: ${id}`, { code: 'MODULE_DEPENDENCY_MISSING' }); + visiting.add(id); + for (const dependency of record.manifest.requires) visit(dependency); + visiting.delete(id); + visited.add(id); + order.push(id); + }; + for (const id of this.#modules.keys()) visit(id); + const capabilities = new Set([...this.#modules.values()].flatMap(record => record.manifest.provides)); + for (const record of this.#modules.values()) { + for (const capability of record.manifest.consumes) { + if (!capabilities.has(capability)) throw new PlatformError(`Fehlende Capability ${capability} für ${record.manifest.id}.`, { code: 'MODULE_CAPABILITY_MISSING' }); + } + } + return order; + } + + async startAll(context) { + this.#startOrder = this.#resolveStartOrder(); + const started = []; + for (const id of this.#startOrder) { + const record = this.#modules.get(id); + if (record.state === 'disabled') continue; + try { + record.state = 'starting'; + await record.lifecycle.start(Object.freeze({ ...context, module: record.manifest })); + record.state = 'running'; + record.startedAt = new Date().toISOString(); + started.push(id); + } catch (error) { + record.state = 'failed'; + record.error = error?.message || String(error); + for (const startedId of started.reverse()) { + const startedRecord = this.#modules.get(startedId); + try { await startedRecord.lifecycle.stop(Object.freeze({ ...context, module: startedRecord.manifest, rollback: true })); } catch {} + startedRecord.state = 'stopped'; + } + throw new PlatformError(`Modulstart fehlgeschlagen: ${id}`, { code: 'MODULE_START_FAILED', cause: error, details: { module: id } }); + } + } + } + + async stopAll(context) { + for (const id of [...this.#startOrder].reverse()) { + const record = this.#modules.get(id); + if (!record || record.state !== 'running') continue; + try { + record.state = 'stopping'; + await record.lifecycle.stop(Object.freeze({ ...context, module: record.manifest })); + record.state = 'stopped'; + } catch (error) { + record.state = 'failed'; + record.error = error?.message || String(error); + } + } + } + + async health() { + const checks = []; + for (const [id, record] of this.#modules) { + if (record.state === 'disabled') { + checks.push({ id, ok: true, state: 'disabled' }); + continue; + } + try { + const result = await record.lifecycle.health({ module: record.manifest }); + checks.push({ id, state: record.state, ok: record.state === 'running' && result?.ok !== false, ...(result || {}) }); + } catch (error) { + checks.push({ id, state: record.state, ok: false, error: error?.message || String(error) }); + } + } + return checks; + } + + snapshot() { + return [...this.#modules.values()].map(record => ({ + ...record.manifest, + state: record.state, + startedAt: record.startedAt, + error: record.error, + })); + } +} diff --git a/app/kernel/platform-kernel.mjs b/app/kernel/platform-kernel.mjs new file mode 100644 index 0000000..a2db3e2 --- /dev/null +++ b/app/kernel/platform-kernel.mjs @@ -0,0 +1,82 @@ +import { EventBus } from './event-bus.mjs'; +import { FeatureFlagRegistry } from './feature-flags.mjs'; +import { LifecycleManager } from './lifecycle.mjs'; +import { ModuleRegistry } from './module-registry.mjs'; +import { PolicyEngine } from './policy-engine.mjs'; +import { RouteRegistry } from './route-registry.mjs'; +import { loadBuiltInModules } from '../modules/catalog.mjs'; + +export function createPlatformKernel({ version, hasPermission }) { + const events = new EventBus(); + const features = new FeatureFlagRegistry(); + const modules = new ModuleRegistry(); + const policies = new PolicyEngine(); + const routes = new RouteRegistry({ policyEngine: policies }); + const lifecycle = new LifecycleManager(); + + policies.register('platform.authenticated', ({ user }) => Boolean(user), { + description: 'Erfordert eine gültige Vendoo-Sitzung.', + }); + policies.register('platform.security-admin', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), { + description: 'Erfordert die Berechtigung security.manage.', + }); + policies.register('themes.use', ({ user }) => Boolean(user), { + owner: 'vendoo.themes', description: 'Erfordert eine gültige Sitzung für persönliche Darstellungseinstellungen.', + }); + policies.register('themes.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), { + owner: 'vendoo.themes', description: 'Erfordert settings.manage für systemweite und eigene Theme-Profile.', + }); + + features.define('platform.module-kernel', { defaultValue: true, description: 'Aktiviert den modularen Plattformkernel.' }); + features.define('platform.route-contracts', { defaultValue: true, description: 'Aktiviert registrierte Routenverträge.' }); + features.define('themes.token-runtime', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den versionierten Design-Token-Vertrag.' }); + features.define('themes.manager', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den sicheren Theme Manager mit Benutzerpräferenzen.' }); + features.define('themes.accessibility-gate', { defaultValue: true, owner: 'vendoo.themes', description: 'Prüft Theme-Kontraste und sichere Tokenwerte.' }); + features.define('extensions.isolated-host', { defaultValue: false, owner: 'vendoo.security', description: 'Reserviert für einen späteren isolierten Extension-Host.' }); + + for (const module of loadBuiltInModules()) modules.register(module); + lifecycle.register({ + id: 'modules', + order: 20, + start: context => modules.startAll(context), + stop: context => modules.stopAll(context), + }); + + const context = Object.freeze({ version, events, features, policies }); + return Object.freeze({ + version, + events, + features, + lifecycle, + modules, + policies, + routes, + async start() { + await lifecycle.start(context); + await events.emit('platform.started', { version }, { owner: 'vendoo.system' }); + }, + async stop() { + await events.emit('platform.stopping', { version }, { owner: 'vendoo.system' }); + await lifecycle.stop(context); + }, + async health() { + const moduleChecks = await modules.health(); + return { + ok: lifecycle.snapshot().state === 'running' && moduleChecks.every(check => check.ok), + lifecycle: lifecycle.snapshot(), + modules: moduleChecks, + }; + }, + snapshot() { + return { + version, + architecture: 'modular-monolith', + lifecycle: lifecycle.snapshot(), + modules: modules.snapshot(), + features: features.list(), + policies: policies.list(), + routes: routes.list(), + }; + }, + }); +} diff --git a/app/kernel/policy-engine.mjs b/app/kernel/policy-engine.mjs new file mode 100644 index 0000000..72613be --- /dev/null +++ b/app/kernel/policy-engine.mjs @@ -0,0 +1,30 @@ +import { PlatformError } from './errors.mjs'; + +const POLICY_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class PolicyEngine { + #policies = new Map(); + + register(id, evaluator, { owner = 'vendoo.security', description = '' } = {}) { + const normalized = String(id || ''); + if (!POLICY_ID.test(normalized)) throw new PlatformError(`Ungültige Policy-ID: ${normalized}`, { code: 'POLICY_ID_INVALID' }); + if (this.#policies.has(normalized)) throw new PlatformError(`Policy bereits registriert: ${normalized}`, { code: 'POLICY_DUPLICATE' }); + if (typeof evaluator !== 'function') throw new PlatformError(`Policy benötigt eine Prüffunktion: ${normalized}`, { code: 'POLICY_EVALUATOR_INVALID' }); + this.#policies.set(normalized, { id: normalized, evaluator, owner, description }); + } + + async evaluate(id, context) { + const policy = this.#policies.get(id); + if (!policy) { + return { allowed: false, reason: 'policy_not_registered', policy: id }; + } + const result = await policy.evaluator(Object.freeze({ ...context })); + if (result === true) return { allowed: true, policy: id }; + if (result === false || result == null) return { allowed: false, reason: 'denied', policy: id }; + return { allowed: Boolean(result.allowed), reason: result.reason || null, policy: id, details: result.details || null }; + } + + list() { + return [...this.#policies.values()].map(({ evaluator: _evaluator, ...policy }) => ({ ...policy })); + } +} diff --git a/app/kernel/route-registry.mjs b/app/kernel/route-registry.mjs new file mode 100644 index 0000000..bcc8cd0 --- /dev/null +++ b/app/kernel/route-registry.mjs @@ -0,0 +1,62 @@ +import { PlatformError, platformErrorPayload } from './errors.mjs'; + +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); +const ROUTE_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; + +export class RouteRegistry { + #routes = new Map(); + #policyEngine; + + constructor({ policyEngine }) { + this.#policyEngine = policyEngine; + } + + register(app, definition) { + const method = String(definition.method || '').toLowerCase(); + const id = String(definition.id || ''); + const path = String(definition.path || ''); + if (!ROUTE_ID.test(id)) throw new PlatformError(`Ungültige Route-ID: ${id}`, { code: 'ROUTE_ID_INVALID' }); + if (!METHODS.has(method)) throw new PlatformError(`Ungültige HTTP-Methode für ${id}.`, { code: 'ROUTE_METHOD_INVALID' }); + if (!path.startsWith('/')) throw new PlatformError(`Ungültiger Routenpfad für ${id}.`, { code: 'ROUTE_PATH_INVALID' }); + if (!definition.policy) throw new PlatformError(`Route ${id} benötigt eine explizite Policy.`, { code: 'ROUTE_POLICY_REQUIRED' }); + if (!definition.owner) throw new PlatformError(`Route ${id} benötigt einen Modulbesitzer.`, { code: 'ROUTE_OWNER_REQUIRED' }); + if (typeof definition.handler !== 'function') throw new PlatformError(`Route ${id} benötigt einen Handler.`, { code: 'ROUTE_HANDLER_REQUIRED' }); + const key = `${method.toUpperCase()} ${path}`; + if (this.#routes.has(key)) throw new PlatformError(`Route bereits registriert: ${key}`, { code: 'ROUTE_DUPLICATE' }); + + const contract = Object.freeze({ + id, + method: method.toUpperCase(), + path, + owner: String(definition.owner), + policy: String(definition.policy), + audit: String(definition.audit || ''), + rateLimit: String(definition.rateLimit || 'default'), + csrf: definition.csrf !== false, + stability: String(definition.stability || 'internal'), + }); + this.#routes.set(key, contract); + + app[method](path, ...(definition.middleware || []), async (req, res, next) => { + try { + const decision = await this.#policyEngine.evaluate(contract.policy, { req, user: req.user, route: contract }); + if (!decision.allowed) { + throw new PlatformError('Keine Berechtigung für diese Aktion.', { + code: 'POLICY_DENIED', status: 403, expose: true, + details: { policy: contract.policy, reason: decision.reason }, + }); + } + await definition.handler(req, res, { route: contract, decision }); + } catch (error) { + if (res.headersSent) return next(error); + const status = Number.isInteger(error?.status) ? error.status : 500; + res.status(status).json(platformErrorPayload(error, req.requestId)); + } + }); + return contract; + } + + list() { + return [...this.#routes.values()].map(route => ({ ...route })); + } +} diff --git a/app/modules/ai/module.json b/app/modules/ai/module.json new file mode 100644 index 0000000..27f0122 --- /dev/null +++ b/app/modules/ai/module.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "vendoo.ai", + "name": "AI Services", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich AI Services; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security", + "vendoo.media" + ], + "permissions": [ + "generation.execute" + ], + "provides": [ + "ai.text", + "ai.images" + ], + "consumes": [ + "media.assets", + "security.policies" + ], + "owns": [ + "ai.providers", + "ai.jobs" + ] +} diff --git a/app/modules/auth/module.json b/app/modules/auth/module.json new file mode 100644 index 0000000..1d24f9b --- /dev/null +++ b/app/modules/auth/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.auth", + "name": "Authentication", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Authentication; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security" + ], + "permissions": [ + "sessions.manage" + ], + "provides": [ + "auth.sessions", + "auth.identity" + ], + "consumes": [ + "security.policies" + ], + "owns": [ + "auth.sessions", + "auth.tokens" + ] +} diff --git a/app/modules/catalog.mjs b/app/modules/catalog.mjs new file mode 100644 index 0000000..ca5a5f1 --- /dev/null +++ b/app/modules/catalog.mjs @@ -0,0 +1,22 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { createThemesModule } from './themes/index.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const moduleNames = ['system', 'security', 'auth', 'users', 'media', 'listings', 'ai', 'flux-studio', 'quality', 'publishing', 'operations']; + +function legacyModule(name) { + return { + manifest: JSON.parse(readFileSync(join(here, name, 'module.json'), 'utf8')), + lifecycle: { + async start() {}, + async stop() {}, + async health() { return { ok: true, bridge: 'legacy' }; }, + }, + }; +} + +export function loadBuiltInModules() { + return [...moduleNames.map(legacyModule), createThemesModule()]; +} diff --git a/app/modules/flux-studio/module.json b/app/modules/flux-studio/module.json new file mode 100644 index 0000000..463e56b --- /dev/null +++ b/app/modules/flux-studio/module.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "vendoo.flux-studio", + "name": "FLUX Studio", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich FLUX Studio; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.ai" + ], + "permissions": [ + "generation.execute" + ], + "provides": [ + "flux.prompt-jobs" + ], + "consumes": [ + "ai.images" + ], + "owns": [ + "flux.generations", + "flux.history" + ] +} diff --git a/app/modules/listings/module.json b/app/modules/listings/module.json new file mode 100644 index 0000000..53d1da2 --- /dev/null +++ b/app/modules/listings/module.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "vendoo.listings", + "name": "Listings & Inventory", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Listings & Inventory; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth", + "vendoo.media" + ], + "permissions": [ + "listings.view", + "listings.edit", + "listings.delete", + "inventory.edit" + ], + "provides": [ + "listings.catalog", + "inventory.stock" + ], + "consumes": [ + "media.assets", + "auth.identity" + ], + "owns": [ + "listings.records", + "inventory.locations" + ] +} diff --git a/app/modules/media/module.json b/app/modules/media/module.json new file mode 100644 index 0000000..53d3b2d --- /dev/null +++ b/app/modules/media/module.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "vendoo.media", + "name": "Media Library", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Media Library; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth" + ], + "permissions": [ + "media.view", + "media.edit", + "media.delete" + ], + "provides": [ + "media.assets", + "media.derivatives" + ], + "consumes": [ + "auth.identity" + ], + "owns": [ + "media.library", + "media.uploads" + ] +} diff --git a/app/modules/operations/module.json b/app/modules/operations/module.json new file mode 100644 index 0000000..2e7ee25 --- /dev/null +++ b/app/modules/operations/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.operations", + "name": "Operations Center", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Operations Center; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.security" + ], + "permissions": [ + "operations.manage" + ], + "provides": [ + "operations.backup", + "operations.update" + ], + "consumes": [ + "security.audit" + ], + "owns": [ + "operations.backups", + "operations.updates" + ] +} diff --git a/app/modules/publishing/module.json b/app/modules/publishing/module.json new file mode 100644 index 0000000..d902367 --- /dev/null +++ b/app/modules/publishing/module.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "vendoo.publishing", + "name": "Publishing", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Publishing; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.listings", + "vendoo.security" + ], + "permissions": [ + "publishing.view", + "publishing.execute", + "publishing.manage" + ], + "provides": [ + "publishing.jobs", + "publishing.adapters" + ], + "consumes": [ + "listings.catalog", + "security.policies" + ], + "owns": [ + "publishing.queue", + "publishing.events" + ] +} diff --git a/app/modules/quality/module.json b/app/modules/quality/module.json new file mode 100644 index 0000000..aa878d4 --- /dev/null +++ b/app/modules/quality/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.quality", + "name": "Quality Center", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Quality Center; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.listings", + "vendoo.ai" + ], + "permissions": [ + "quality.execute" + ], + "provides": [ + "quality.reports" + ], + "consumes": [ + "listings.catalog", + "ai.text" + ], + "owns": [ + "quality.history" + ] +} diff --git a/app/modules/security/module.json b/app/modules/security/module.json new file mode 100644 index 0000000..9fbfe04 --- /dev/null +++ b/app/modules/security/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.security", + "name": "Security Core", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Security Core; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.system" + ], + "permissions": [ + "security.manage" + ], + "provides": [ + "security.policies", + "security.audit" + ], + "consumes": [ + "platform.events" + ], + "owns": [ + "security.permissions", + "security.rate-limits" + ] +} diff --git a/app/modules/system/module.json b/app/modules/system/module.json new file mode 100644 index 0000000..4c27489 --- /dev/null +++ b/app/modules/system/module.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "id": "vendoo.system", + "name": "System Kernel", + "version": "1.0.0", + "type": "core", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich System Kernel; schrittweise Migration ohne Funktionsbruch.", + "requires": [], + "permissions": [ + "dashboard.view" + ], + "provides": [ + "platform.lifecycle", + "platform.events", + "platform.features" + ], + "consumes": [], + "owns": [ + "system.runtime", + "system.health" + ] +} diff --git a/app/modules/themes/index.mjs b/app/modules/themes/index.mjs new file mode 100644 index 0000000..ec510d4 --- /dev/null +++ b/app/modules/themes/index.mjs @@ -0,0 +1,27 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { PlatformError } from '../../kernel/errors.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8')); +let service = null; + +export function getThemeService() { + if (!service) throw new PlatformError('Theme-Modul ist noch nicht gestartet.', { code: 'THEME_MODULE_NOT_READY', status: 503, expose: true }); + return service; +} + +export function createThemesModule() { + return { + manifest, + lifecycle: { + async start() { + const { createThemeService } = await import('./service.mjs'); + service = createThemeService(); + }, + async stop() { service = null; }, + async health() { return getThemeService().health(); }, + }, + }; +} diff --git a/app/modules/themes/migrations/001_theme_profiles.sql b/app/modules/themes/migrations/001_theme_profiles.sql new file mode 100644 index 0000000..d3207f3 --- /dev/null +++ b/app/modules/themes/migrations/001_theme_profiles.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS theme_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme TEXT NOT NULL DEFAULT 'light', + values_json TEXT NOT NULL DEFAULT '{}', + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS user_theme_preferences ( + user_id INTEGER PRIMARY KEY, + theme_id TEXT NOT NULL DEFAULT 'inherit', + density TEXT NOT NULL DEFAULT 'comfortable', + reduced_motion TEXT NOT NULL DEFAULT 'system', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC); diff --git a/app/modules/themes/module.json b/app/modules/themes/module.json new file mode 100644 index 0000000..41606c3 --- /dev/null +++ b/app/modules/themes/module.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "vendoo.themes", + "name": "Themes & Design System", + "version": "1.1.0", + "type": "feature", + "status": "native", + "description": "Natives Theme- und Design-System-Modul mit sicherem Token-Editor, Benutzerpräferenzen und Accessibility-Prüfungen.", + "requires": [ + "vendoo.system" + ], + "permissions": [ + "settings.manage", + "themes.use" + ], + "provides": [ + "themes.tokens", + "themes.runtime", + "themes.profiles", + "themes.accessibility" + ], + "consumes": [ + "platform.events" + ], + "owns": [ + "themes.definitions", + "themes.user-preferences", + "themes.system-default" + ] +} diff --git a/app/modules/themes/repository.mjs b/app/modules/themes/repository.mjs new file mode 100644 index 0000000..93f32fc --- /dev/null +++ b/app/modules/themes/repository.mjs @@ -0,0 +1,95 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { db } from '../../../lib/db.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const migrationSql = readFileSync(join(here, 'migrations', '001_theme_profiles.sql'), 'utf8'); + +export function ensureThemeSchema() { + db.exec(migrationSql); +} + +function parseProfile(row) { + if (!row) return null; + let values = {}; + try { values = JSON.parse(row.values_json || '{}'); } catch {} + return { ...row, values }; +} + +export function listThemeProfiles() { + ensureThemeSchema(); + return db.prepare('SELECT * FROM theme_profiles ORDER BY name COLLATE NOCASE, id').all().map(parseProfile); +} + +export function getThemeProfile(id) { + ensureThemeSchema(); + return parseProfile(db.prepare('SELECT * FROM theme_profiles WHERE id = ?').get(id)); +} + +export function saveThemeProfile(profile, userId) { + ensureThemeSchema(); + db.prepare(`INSERT INTO theme_profiles (id, name, base_theme, values_json, created_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, base_theme = excluded.base_theme, + values_json = excluded.values_json, updated_at = datetime('now')`).run( + profile.id, profile.name, profile.base_theme, JSON.stringify(profile.values || {}), userId || null, + ); + return getThemeProfile(profile.id); +} + +export function deleteThemeProfile(id) { + ensureThemeSchema(); + const transaction = db.transaction(() => { + db.prepare("UPDATE user_theme_preferences SET theme_id = 'inherit', updated_at = datetime('now') WHERE theme_id = ?").run(id); + const system = db.prepare("SELECT value FROM settings WHERE key = 'theme_system_default'").get(); + if (system?.value === id) db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('theme_system_default', 'system')").run(); + return db.prepare('DELETE FROM theme_profiles WHERE id = ?').run(id).changes; + }); + return transaction(); +} + +export function getUserThemePreference(userId) { + ensureThemeSchema(); + return db.prepare('SELECT theme_id, density, reduced_motion, updated_at FROM user_theme_preferences WHERE user_id = ?').get(userId) || { + theme_id: 'inherit', density: 'comfortable', reduced_motion: 'system', updated_at: null, + }; +} + +export function saveUserThemePreference(userId, preference) { + ensureThemeSchema(); + db.prepare(`INSERT INTO user_theme_preferences (user_id, theme_id, density, reduced_motion) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET theme_id = excluded.theme_id, density = excluded.density, + reduced_motion = excluded.reduced_motion, updated_at = datetime('now')`).run( + userId, preference.theme_id, preference.density, preference.reduced_motion, + ); + return getUserThemePreference(userId); +} + +function getSetting(key, fallback) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row?.value ?? fallback; +} + +function setSetting(key, value) { + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, String(value)); +} + +export function getThemeSystemDefault() { + return { + theme_id: getSetting('theme_system_default', 'system'), + density: getSetting('theme_system_density', 'comfortable'), + reduced_motion: getSetting('theme_system_reduced_motion', 'system'), + }; +} + +export function saveThemeSystemDefault(preference) { + const transaction = db.transaction(() => { + setSetting('theme_system_default', preference.theme_id); + setSetting('theme_system_density', preference.density); + setSetting('theme_system_reduced_motion', preference.reduced_motion); + }); + transaction(); + return getThemeSystemDefault(); +} diff --git a/app/modules/themes/service.mjs b/app/modules/themes/service.mjs new file mode 100644 index 0000000..3074d39 --- /dev/null +++ b/app/modules/themes/service.mjs @@ -0,0 +1,85 @@ +import { PlatformError } from '../../kernel/errors.mjs'; +import { + evaluateThemeAccessibility, getBaseThemeValues, getBuiltinThemes, getThemeContract, + resolveThemeValues, validateThemeDefinition, validateThemePreference, +} from '../../core/themes/theme-contract.mjs'; +import { + deleteThemeProfile, ensureThemeSchema, getThemeProfile, getThemeSystemDefault, + getUserThemePreference, listThemeProfiles, saveThemeProfile, saveThemeSystemDefault, + saveUserThemePreference, +} from './repository.mjs'; + +function decorate(profile) { + return { + ...profile, + builtin: false, + resolved_values: resolveThemeValues({ baseTheme: profile.base_theme, values: profile.values }), + accessibility: evaluateThemeAccessibility({ baseTheme: profile.base_theme, values: profile.values }), + }; +} + +function customExists(id) { + return Boolean(getThemeProfile(id)); +} + +function effectivePreference(userPreference, systemDefault) { + return userPreference.theme_id === 'inherit' ? { ...systemDefault, inherited: true } : { ...userPreference, inherited: false }; +} + +export function createThemeService() { + ensureThemeSchema(); + return Object.freeze({ + health() { + ensureThemeSchema(); + return { ok: true, profiles: listThemeProfiles().length, contract: getThemeContract().contractVersion }; + }, + catalog(userId) { + const custom = listThemeProfiles().map(decorate); + const preference = getUserThemePreference(userId); + const systemDefault = getThemeSystemDefault(); + return { + contract: getThemeContract(), + builtin: getBuiltinThemes(), + custom, + preference, + system_default: systemDefault, + effective_preference: effectivePreference(preference, systemDefault), + }; + }, + validate(input) { + const theme = validateThemeDefinition(input, { allowExistingId: true }); + return { theme, resolved_values: resolveThemeValues({ baseTheme: theme.base_theme, values: theme.values }), accessibility: theme.accessibility }; + }, + create(input, userId) { + const theme = validateThemeDefinition(input); + if (getThemeProfile(theme.id)) throw new PlatformError('Theme-ID ist bereits vorhanden.', { code: 'THEME_DUPLICATE', status: 409, expose: true }); + return decorate(saveThemeProfile(theme, userId)); + }, + update(id, input, userId) { + if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + const theme = validateThemeDefinition({ ...input, id }, { allowExistingId: true }); + return decorate(saveThemeProfile(theme, userId)); + }, + remove(id) { + if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + deleteThemeProfile(id); + return { ok: true, id }; + }, + saveUserPreference(userId, input) { + const preference = validateThemePreference(input, { customThemeExists: customExists }); + return saveUserThemePreference(userId, preference); + }, + saveSystemDefault(input) { + const preference = validateThemePreference(input, { customThemeExists: customExists }); + if (preference.theme_id === 'inherit') throw new PlatformError('Der Systemstandard kann nicht auf „Vererben“ gesetzt werden.', { code: 'THEME_SYSTEM_INHERIT_INVALID', status: 400, expose: true }); + return saveThemeSystemDefault(preference); + }, + exportTheme(id) { + const profile = getThemeProfile(id); + if (!profile) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true }); + return { schemaVersion: 1, type: 'vendoo-theme', theme: { id: profile.id, name: profile.name, base_theme: profile.base_theme, values: profile.values } }; + }, + }); +} + +export { getBaseThemeValues }; diff --git a/app/modules/users/module.json b/app/modules/users/module.json new file mode 100644 index 0000000..16cd65a --- /dev/null +++ b/app/modules/users/module.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "vendoo.users", + "name": "Users & Roles", + "version": "1.0.0", + "type": "feature", + "status": "legacy-bridge", + "description": "Vertrag für den bestehenden Vendoo-Bereich Users & Roles; schrittweise Migration ohne Funktionsbruch.", + "requires": [ + "vendoo.auth" + ], + "permissions": [ + "users.manage" + ], + "provides": [ + "users.directory", + "users.roles" + ], + "consumes": [ + "auth.identity" + ], + "owns": [ + "users.accounts", + "users.preferences" + ] +} diff --git a/bootstrap.mjs b/bootstrap.mjs new file mode 100644 index 0000000..550339f --- /dev/null +++ b/bootstrap.mjs @@ -0,0 +1,25 @@ +import dotenv from 'dotenv'; +import { existsSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = dirname(fileURLToPath(import.meta.url)); +const rootEnvPath = join(appRoot, '.env'); + +// Lokaler Windows-/Entwicklungsbetrieb: klassische .env zuerst laden. +if (existsSync(rootEnvPath)) dotenv.config({ path: rootEnvPath, override: false }); + +// Docker/NAS/VPS: schreibbarer persistenter Overlay-Speicher für Eingabemasken. +const configuredPath = String(process.env.VENDOO_CONFIG_PATH || '').trim(); +const defaultPath = process.env.VENDOO_DATA_DIR + ? join(String(process.env.VENDOO_DATA_DIR), 'config', 'vendoo.env') + : rootEnvPath; +const configCandidate = configuredPath || defaultPath; +const runtimeConfigPath = resolve(isAbsolute(configCandidate) ? configCandidate : join(appRoot, configCandidate)); +process.env.VENDOO_CONFIG_PATH = runtimeConfigPath; + +if (runtimeConfigPath !== rootEnvPath && existsSync(runtimeConfigPath)) { + dotenv.config({ path: runtimeConfigPath, override: true }); +} + +await import('./server.mjs'); diff --git a/compose.bind-mounts.example.yaml b/compose.bind-mounts.example.yaml new file mode 100644 index 0000000..c873c69 --- /dev/null +++ b/compose.bind-mounts.example.yaml @@ -0,0 +1,11 @@ +# Optionaler Override für NAS-Bind-Mounts. +# Vorher ./runtime/{db,uploads,backups,operations,logs} anlegen und UID/GID 1000 Schreibrechte geben. +services: + vendoo: + volumes: + - ./runtime/db:/app/data/db + - ./runtime/uploads:/app/data/uploads + - ./runtime/backups:/app/data/backups + - ./runtime/operations:/app/data/operations + - ./runtime/logs:/app/data/logs + - ./runtime/config:/app/data/config diff --git a/compose.vps.yaml b/compose.vps.yaml new file mode 100644 index 0000000..ad3a375 --- /dev/null +++ b/compose.vps.yaml @@ -0,0 +1,106 @@ +name: vendoo +services: + vendoo: + build: + context: . + dockerfile: Dockerfile + image: vendoo:1.36.0 + container_name: vendoo + restart: unless-stopped + init: true + env_file: + - .env + environment: + NODE_ENV: production + PORT: 8124 + VENDOO_BIND_HOST: 0.0.0.0 + VENDOO_TRUST_PROXY: "true" + VENDOO_COOKIE_SECURE: "true" + VENDOO_DATA_DIR: /app/data + VENDOO_DB_PATH: /app/data/db/vendoo.db + VENDOO_UPLOADS_DIR: /app/data/uploads + VENDOO_BACKUP_DIR: /app/data/backups + VENDOO_OPERATIONS_DIR: /app/data/operations + VENDOO_LOG_DIR: /app/data/logs + VENDOO_CONFIG_PATH: /app/data/config/vendoo.env + VENDOO_REQUIRE_SETUP_TOKEN: "true" + LOCAL_IMAGE_ENABLED: "${LOCAL_IMAGE_ENABLED:-false}" + expose: + - "8124" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - vendoo_db:/app/data/db + - vendoo_uploads:/app/data/uploads + - vendoo_backups:/app/data/backups + - vendoo_operations:/app/data/operations + - vendoo_logs:/app/data/logs + - vendoo_config:/app/data/config + read_only: true + tmpfs: + - /tmp:size=512m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + stop_grace_period: 15s + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - vendoo + + caddy: + image: caddy:2-alpine + container_name: vendoo-caddy + restart: unless-stopped + depends_on: + vendoo: + condition: service_healthy + environment: + VENDOO_DOMAIN: "${VENDOO_DOMAIN}" + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + pids_limit: 128 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + networks: + - vendoo + +networks: + vendoo: + driver: bridge + +volumes: + vendoo_db: + vendoo_uploads: + vendoo_backups: + vendoo_operations: + vendoo_logs: + vendoo_config: + caddy_data: + caddy_config: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..02f87fa --- /dev/null +++ b/compose.yaml @@ -0,0 +1,72 @@ +name: vendoo +services: + vendoo: + build: + context: . + dockerfile: Dockerfile + image: vendoo:1.36.0 + container_name: vendoo + restart: unless-stopped + init: true + env_file: + - .env + environment: + NODE_ENV: production + PORT: 8124 + VENDOO_BIND_HOST: 0.0.0.0 + VENDOO_DATA_DIR: /app/data + VENDOO_DB_PATH: /app/data/db/vendoo.db + VENDOO_UPLOADS_DIR: /app/data/uploads + VENDOO_BACKUP_DIR: /app/data/backups + VENDOO_OPERATIONS_DIR: /app/data/operations + VENDOO_LOG_DIR: /app/data/logs + VENDOO_CONFIG_PATH: /app/data/config/vendoo.env + VENDOO_REQUIRE_SETUP_TOKEN: "true" + LOCAL_IMAGE_ENABLED: "${LOCAL_IMAGE_ENABLED:-false}" + ports: + - "${VENDOO_PUBLISH_ADDRESS:-127.0.0.1}:${VENDOO_HOST_PORT:-8124}:8124" + expose: + - "8124" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - vendoo_db:/app/data/db + - vendoo_uploads:/app/data/uploads + - vendoo_backups:/app/data/backups + - vendoo_operations:/app/data/operations + - vendoo_logs:/app/data/logs + - vendoo_config:/app/data/config + read_only: true + tmpfs: + - /tmp:size=512m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + stop_grace_period: 15s + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - vendoo + +networks: + vendoo: + driver: bridge + +volumes: + vendoo_db: + vendoo_uploads: + vendoo_backups: + vendoo_operations: + vendoo_logs: + vendoo_config: diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..1702926 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,278 @@ +CREATE TABLE IF NOT EXISTS listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + ai_provider TEXT NOT NULL, + title TEXT, + description TEXT, + description_html TEXT, + tags TEXT, + photos TEXT, + seller_notes TEXT, + brand TEXT, + category TEXT, + size TEXT, + color TEXT, + condition TEXT, + price REAL, + status TEXT DEFAULT 'active', + language TEXT DEFAULT 'de', + created_by INTEGER, + updated_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + platform TEXT, + seller_notes TEXT, + language TEXT DEFAULT 'de', + default_tags TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS publish_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + status TEXT DEFAULT 'pending', + external_id TEXT, + external_url TEXT, + offer_id TEXT, + sync_status TEXT, + last_synced_at TEXT, + ended_at TEXT, + updated_at TEXT, + published_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publish_log_external ON publish_log(platform, external_id); + +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_platform', 'vinted'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_ai', 'claude'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('seller_notes', ''); +INSERT OR IGNORE INTO settings (key, value) VALUES ('default_language', 'de'); + +CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at); + + +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photos_enabled', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_variants', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_default_preset', 'mixed'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_underwear', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_block_kids', '1'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('ai_model_photo_provider', 'openai'); + +CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id); + +CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (generation_id) REFERENCES ai_model_generations(id) ON DELETE CASCADE, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id); + + +CREATE TABLE IF NOT EXISTS flux_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + image_path TEXT NOT NULL UNIQUE, + width INTEGER, + height INTEGER, + seed INTEGER, + steps INTEGER, + style TEXT DEFAULT 'none', + edited_from INTEGER, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_flux_images_created ON flux_images(created_at DESC); + + +CREATE TABLE IF NOT EXISTS quality_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + grade TEXT NOT NULL DEFAULT 'E', + ready INTEGER NOT NULL DEFAULT 0, + blockers INTEGER NOT NULL DEFAULT 0, + warnings INTEGER NOT NULL DEFAULT 0, + recommendations INTEGER NOT NULL DEFAULT 0, + report_json TEXT NOT NULL DEFAULT '{}', + analyzed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_quality_reports_listing ON quality_reports(listing_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_quality_reports_score ON quality_reports(score, analyzed_at DESC); + + +CREATE TABLE IF NOT EXISTS publishing_jobs ( + id TEXT PRIMARY KEY, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'api', + action TEXT NOT NULL DEFAULT 'publish', + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + idempotency_key TEXT NOT NULL UNIQUE, + external_id TEXT, + external_url TEXT, + offer_id TEXT, + retries INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_attempt_at TEXT, + claimed_by TEXT, + claimed_at TEXT, + error_code TEXT, + error_message TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publishing_jobs_status ON publishing_jobs(status, next_attempt_at, created_at); +CREATE INDEX IF NOT EXISTS idx_publishing_jobs_listing ON publishing_jobs(listing_id, platform, created_at DESC); + +CREATE TABLE IF NOT EXISTS publishing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES publishing_jobs(id) ON DELETE SET NULL, + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_publishing_events_job ON publishing_events(job_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_publishing_events_listing ON publishing_events(listing_id, id DESC); + + +-- Mehrbenutzer- und Sicherheitsgrundlage 1.31.0 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'editor', + active INTEGER NOT NULL DEFAULT 1, + avatar_color TEXT, + last_active_at TEXT, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + password_changed_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + invited_by INTEGER +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + ip TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + acquired_at TEXT NOT NULL DEFAULT (datetime('now')), + renewed_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + client_label TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(resource_type, resource_id) +); +CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at); +CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at); + +-- Natives Theme-Modul 1.36.0 +CREATE TABLE IF NOT EXISTS theme_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme TEXT NOT NULL DEFAULT 'light', + values_json TEXT NOT NULL DEFAULT '{}', + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE TABLE IF NOT EXISTS user_theme_preferences ( + user_id INTEGER PRIMARY KEY, + theme_id TEXT NOT NULL DEFAULT 'inherit', + density TEXT NOT NULL DEFAULT 'comfortable', + reduced_motion TEXT NOT NULL DEFAULT 'system', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC); diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..9855bbd --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,16 @@ +{ + admin off +} + +{$VENDOO_DOMAIN} { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + reverse_proxy vendoo:8124 +} diff --git a/docs/ABNAHMEBERICHT_1_32_0.md b/docs/ABNAHMEBERICHT_1_32_0.md new file mode 100644 index 0000000..58e2347 --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_32_0.md @@ -0,0 +1,60 @@ +# Vendoo 1.32.0 – Abnahmebericht + +**Release:** Docker, NAS & Production Deployment Foundation +**Prüfdatum:** 09.07.2026 +**Ausgangsbasis:** `Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip` +**Basis-SHA-256:** `cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b` + +## Ergebnis + +Vendoo 1.32.0 ist als vollständiges Drüberkopierpaket erstellt. Die Code-, Migrations-, Pfad-, Sicherheits- und Paketprüfungen sind bestanden. Die reale Docker-/NAS-, Windows- und Browser-End-to-End-Abnahme konnte in der bereitgestellten Linux-Sandbox nicht ausgeführt werden, weil dort weder Docker noch PowerShell/Windows noch ein Browser zur Verfügung stehen und das native `better-sqlite3`-Modul in dieser stark begrenzten Build-Umgebung nicht innerhalb des Ausführungszeitlimits kompiliert werden konnte. + +Der Release ist deshalb **für Installation und reale Umgebungsabnahme vorbereitet**, aber Docker-, Windows- und Browserbetrieb werden nicht fälschlich als praktisch getestet ausgewiesen. + +## Bestandene Prüfungen + +| Prüfung | Status | Ergebnis/Nachweis | +|---|---|---| +| Handover-Reihenfolge | bestanden | `00_START_HIER.md` bis `14_SOURCE_PACKAGE_CHECKSUM.txt` vollständig in vorgegebener Reihenfolge gelesen | +| Basis-ZIP SHA-256 | bestanden | stimmt exakt mit Handover überein | +| Basis-Dateiprüfsummen | bestanden | 271 von 271 Dateien `OK` | +| Reale 1.31.0-Codeanalyse | bestanden | RBAC, Sessions, Audit, Locks, Setup, Windows-Menü, FLUX-Trennung und reale Deployment-Lücken geprüft | +| Versionssynchronität | bestanden | Paket, Lockfile, Manifest, UI-Build und Windows-Setup = `1.32.0` | +| JS-/MJS-Syntax | bestanden | alle geänderten Laufzeitdateien sowie Verifikator mit `node --check` | +| Shell-Syntax | bestanden | Entry-Point, Backup und Restore mit `sh -n` | +| YAML-Syntax | bestanden | `compose.yaml` und Bind-Mount-Override mit PyYAML geladen | +| Datenbankmigration frisch | bestanden | 68 additive DB-Ausführungsblöcke; Pflichttabellen vorhanden | +| Datenbankmigration bestehend | bestanden | vorhandener Testdatensatz blieb erhalten; Publishing- und Operations-Struktur gültig | +| Lockfile-Auflösung | bestanden | sauberer `npm ci --ignore-scripts`: 151 Pakete installiert | +| Laufzeitpfade | bestanden | DB, Uploads, Backups, Operations und Logs in getrenntem temporären Datenbereich beschreibbar | +| Setup-Token Entry-Point | bestanden | fehlender Token beendet Produktion mit Exit 78; 32-Zeichen-Token akzeptiert | +| Deployment-Gate | bestanden | Version, Pfadtrennung, Readiness, Shutdown, Setup-Schutz, Origins, Container-Härtung und Datenhygiene | +| Datenhygiene | bestanden | keine `.env`, DB, Logs, Uploads, Backups, Operations, Modelle oder `node_modules` im Anwendungspaket | +| Secret-Musterscan | bestanden | keine erkannten privaten Schlüssel, AWS- oder `sk-`-Live-Schlüssel | +| FLUX/VTO-Regel | bestanden | FLUX bleibt getrennt; Hauptcontainer standardmäßig deaktiviert; freier Workflow ohne `LoadImage`; kein VTO-Wiedereinbau | +| Originalbildschutz | bestanden | geänderte Pfadlogik erzeugt keine destruktive Originalüberschreibung | + +## Nicht ausgeführte beziehungsweise nicht vollständig ausführbare Prüfungen + +| Prüfung | Status | Grund | +|---|---|---| +| vollständiges `npm ci` mit nativen Install-Skripten | nicht abgeschlossen | Registry-/GitHub-Prebuild war nicht verfügbar; lokaler `better-sqlite3`-Fallback kompilierte in der Sandbox auch nach 5 bzw. 10 Minuten nicht fertig | +| Serverstart und API-Smoke-Test | nicht ausgeführt | natives `better-sqlite3`-Binary dadurch nicht verfügbar | +| Docker-Build | nicht ausgeführt | `docker` ist in der Sandbox nicht installiert (`exit 127`) | +| `docker compose config` über Docker CLI | nicht ausgeführt | Docker fehlt; YAML wurde statisch erfolgreich validiert | +| Container-Healthcheck/Persistenz/Backup/Restore | nicht ausgeführt | Docker fehlt; Skripte und Mount-Logik wurden statisch geprüft | +| Reverse-Proxy-/HTTPS-Livetest | nicht ausgeführt | keine Proxy-/TLS-Laufzeit vorhanden | +| Windows-Start/Update | nicht ausgeführt | kein Windows und kein PowerShell vorhanden; reale Menüpunkte 2 und 3 statisch bestätigt | +| Login, Session, Logout, Rollen, Upload, Artikel, SmartCopy, Publish-Center | nicht ausgeführt | Server-/Browserlaufzeit nicht verfügbar | +| Dark Mode, Responsive, Tastatur/Fokus | nicht ausgeführt | kein Browser-End-to-End-Test; dieser Slice verändert keine Haupt-UI-Layouts, nur das Setup-Token-Feld im Login | +| echte Plattform-APIs | nicht ausgeführt | bewusst keine Veröffentlichung ausgelöst | + +## Reale Abnahme nach Installation + +Der Betreiber muss die in `INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md` beschriebene Funktionsprüfung ausführen. Besonders wichtig sind `/readyz`, Login/Logout, Rollen, bestehende Artikel und Bilder, Upload, Containerneustart, Testbackup, Restore in einem sicheren Testpfad sowie ein Reverse-Proxy-Test mit HTTPS. + +## Bekannte Folgepunkte + +- Widerrufbare, minimale Browser-Extension-Tokenkopplung statt dauerhafter Cookie-Abhängigkeit bleibt ein späterer Security-Slice. +- Offline-Notfallverfahren für den verlorenen letzten Administrator bleibt ein späterer Operations-Slice. +- Ein veröffentlichtes, signiertes Container-Image und automatisierte CI-Builds sind noch nicht Bestandteil dieses Pakets. diff --git a/docs/ABNAHMEBERICHT_1_33_0.md b/docs/ABNAHMEBERICHT_1_33_0.md new file mode 100644 index 0000000..dc1773d --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_33_0.md @@ -0,0 +1,34 @@ +# Vendoo 1.33.0 – Abnahmebericht + +**Release:** Multiplatform Deployment Wizard & Flux/Admin Fixes +**Prüfdatum:** 09.07.2026 +**Basis:** Vendoo 1.32.0 + +## Bestandene statische Prüfungen + +- Versionsgleichstand in Paket, Lockfile, UI, Compose, Manifest und Windows-Setup +- JavaScript-/MJS-Syntax der geänderten Dateien +- POSIX-Shell-Syntax von `setup.sh` +- Deployment-Gate 1.33.0 +- Windows-Assistent enthält alle vier Zieltypen +- Linux-Assistent enthält Docker-, NAS- und VPS-Modus +- Compose behält non-root, read-only, Healthcheck und getrennte Volumes +- FLUX-Historie wählt beim Laden kein Bild mehr automatisch aus +- Reset-Funktion löscht weder Verlauf noch Bilder noch Jobs +- Benutzeraktionen verwenden gebundene Event-Listener +- Passwortvalidierung findet vor dem Benutzer-Update statt +- Benutzer-Update liefert einen sanitiserten Datensatz +- keine produktive `.env`, DB, Uploads, Backups, Logs, Operations oder `node_modules` im Release + +## Nicht als praktisch getestet ausgewiesen + +- Windows PowerShell 5.1 Laufzeit, da die Sandbox kein Windows/PowerShell enthält +- Docker-Build und Containerstart, da kein Docker-Daemon vorhanden ist +- Portainer-Import und herstellerspezifische NAS-Oberflächen +- realer VPS-Reverse-Proxy und TLS +- Browser-End-to-End-Klicktest +- native Serverlaufzeit mit `better-sqlite3` + +## Abnahmeentscheidung + +Das Paket ist als Drüberkopier-Release und als geführte Installationsgrundlage vorbereitet. Die reale Zielsystemabnahme muss nach `02_INSTALLATION_UND_ABNAHME.md` erfolgen. Kein nicht ausgeführter Laufzeittest wird als bestanden dargestellt. diff --git a/docs/ABNAHMEBERICHT_1_34_0.md b/docs/ABNAHMEBERICHT_1_34_0.md new file mode 100644 index 0000000..c79f13a --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_34_0.md @@ -0,0 +1,50 @@ +# Vendoo 1.34.0 – Abnahmebericht + +**Release:** GitHub CI/CD & Zero-Edit Installer Foundation +**Prüfdatum:** 09.07.2026 +**Basis:** Vendoo 1.33.0 + +## Umgesetzte Abnahmepunkte + +- geführte Windows-Installationsmaske für lokalen Betrieb und Docker Desktop, +- geführter Shell-Assistent für Linux, macOS, NAS/Portainer und VPS, +- keine manuelle Bearbeitung von `.env`, Compose oder Caddy-Konfiguration erforderlich, +- automatisch erzeugter Erst-Admin-Code, +- Eingabevalidierung für Port, URL und Domain, +- automatisches VPS-HTTPS über getrennten Caddy-Stack, +- persistenter Runtime-Konfigurationsspeicher für Einstellungen im read-only Container, +- CI für reproduzierbare Installation, Syntax, Migrationen, Git-Sicherheit, Compose und Docker-Testbuild, +- Release-Automation für ZIP, SHA-256 und GHCR-Images, +- kontrollierter Git-Erstimport mit lokalem Sicherheitsgate, +- real angelegter Branch `release/1.34.0-initial-import`, +- real angelegter Draft Pull Request #3. + +## Bestandene Prüfungen + +- JavaScript-/MJS-Syntaxprüfung, +- POSIX-Shell-Syntaxprüfung, +- simulierte Installationsläufe für Docker lokal, NAS-Bind-Mount und VPS/Caddy, +- dabei erkannter und behobener Standardwert-/Prompt-Fehler im Shell-Assistenten, +- YAML-Parsing aller Compose- und GitHub-Workflow-Dateien, +- Deployment-Gate 1.34.0, +- Git-Sicherheitsgate, +- Datenbankmigrationstest gegen frische und bestehende Testdatenbank, +- bestehender Testdatensatz blieb erhalten, +- persistenter Konfigurationsspeicher einschließlich Dateirechten und Mehrzeilen-Sperre, +- Versionsgleichstand von Paket, Lockfile, UI, Manifest, Compose und Installer, +- kein produktives Secret oder Laufzeitdatum im bereinigten Release. + +## Nicht als praktisch getestet ausgewiesen + +- tatsächlicher WinForms-/PowerShell-5.1-Lauf auf Windows, +- Docker-Build und Containerstart mit realem Docker-Daemon, +- Docker Desktop auf Windows/macOS, +- Portainer-Import auf einem realen NAS, +- Caddy-Zertifikatsausstellung mit echter Domain und öffentlichem DNS, +- Browser-End-to-End-Abnahme, +- vollständige native Node-Laufzeit mit kompiliertem `better-sqlite3`, +- veröffentlichter GitHub-Release-Workflow und GHCR-Push. + +## Abnahmeentscheidung + +Das Release ist als vollständiges Drüberkopier-Paket, Zero-Edit-Installationsgrundlage und kontrollierte GitHub-Importbasis freigegeben. Zielsystemtests bleiben gemäß Installations- und Abnahmeanleitung verpflichtend. Nicht ausgeführte Laufzeittests werden nicht als bestanden dargestellt. diff --git a/docs/ABNAHMEBERICHT_1_34_1.md b/docs/ABNAHMEBERICHT_1_34_1.md new file mode 100644 index 0000000..cf7bd88 --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_34_1.md @@ -0,0 +1,20 @@ +# Vendoo 1.34.1 – Abnahmebericht + +## Bestanden + +- JavaScript-Syntax des Git-Sicherheitsgates und des Regressionstests +- vollständiger Arbeitsbaumscan auf sauberem Release-Quellstand +- Blockierung unversionierter `.env.local`- und Token-Testdateien +- erfolgreicher Lauf nach Entfernung der Test-Secrets +- Deployment-Gate 1.34.1 +- Shell-Syntax des Linux/macOS-Deploy-Assistenten +- Versionsgleichstand in Paket, Lockfile, UI, Setup und Manifest +- GitHub-Branch `release/1.34.1-initial-import` und Draft Pull Request #4 real angelegt +- Pull Request #3 geschlossen und als ersetzt dokumentiert + +## Nicht als real getestet ausgewiesen + +- vollständiger Lauf unter Windows PowerShell 5.1 +- echte Browser-Anmeldung und kompletter Push vom Nutzer-PC +- GitHub Actions nach vollständigem Quellimport +- Docker-/NAS-/VPS-Laufzeit, da dieser Hotfix ausschließlich das Git-Sicherheitsgate betrifft diff --git a/docs/ABNAHMEBERICHT_1_34_2.md b/docs/ABNAHMEBERICHT_1_34_2.md new file mode 100644 index 0000000..06b8647 --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_34_2.md @@ -0,0 +1,25 @@ +# Vendoo 1.34.2 – Abnahmebericht + +## Bestanden + +- JavaScript-Syntax von Staging-Builder, Staging-Regressionstest, Sicherheitsgate und Deployment-Gate +- Shell-Syntax des Linux/macOS-Deploy-Assistenten +- YAML-Parsing von CI, Release und Compose-Dateien +- Git-Staging-Regressionstest: sichere Dateien kopiert, lokale `.env.local`, Operations-, Log-, Upload-, DB- und Modelltestdaten ausgeschlossen +- `.git` des temporären Klons blieb unverändert erhalten +- Git-Sicherheitsgate auf dem vollständigen Release-Quellstand +- Secret-Regressionstest mit unversionierten Testdateien +- Deployment-Gate 1.34.2 +- Shell-Installer-Simulation für Docker, NAS und VPS +- Datenbankmigrationen: 68 Einheiten auf frischer und bestehender Datenbank +- vorhandener Testdatensatz blieb erhalten +- Versionsgleichstand in Paket, Lockfile, UI, Setup, Compose und Update-Manifest + +## Nicht als real getestet ausgewiesen + +- vollständiger Lauf unter Windows PowerShell 5.1 +- echte GitHub-Browseranmeldung und kompletter Push vom Nutzer-PC +- GitHub Actions nach vollständigem Quellimport +- echter Docker-/NAS-/VPS-Lauf + +Die Windows-spezifische Prozessausgabe wurde statisch geprüft; eine echte Windows-Laufzeit steht in der Erstellungsumgebung nicht zur Verfügung. diff --git a/docs/ABNAHMEBERICHT_1_34_3.md b/docs/ABNAHMEBERICHT_1_34_3.md new file mode 100644 index 0000000..db82b5d --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_34_3.md @@ -0,0 +1,20 @@ +# Vendoo 1.34.3 – Abnahmebericht + +## Bestanden + +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1` +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/setup.bat` +- Staging-Regression mit `updates/backups/slice22_1_20260707-194953/setup.ps1` +- aktive Root-/scripts-Installationsdateien bleiben im Staging +- Git-Sicherheitsgate auf bereinigtem Quellstand +- JavaScript-/MJS-Syntaxprüfung +- Deployment-Gate 1.34.3 +- Shell-Installer-Simulation +- Datenbankmigrationsprüfung gegen frische und bestehende Testdatenbank + +## Nicht als real getestet + +- Windows PowerShell 5.1 auf dem Benutzerrechner +- echter Push aus dem lokalen Projektordner +- Docker-/Portainer-/VPS-Livetest +- GitHub-Actions-Lauf nach dem vollständigen Import diff --git a/docs/ABNAHMEBERICHT_1_35_0.md b/docs/ABNAHMEBERICHT_1_35_0.md new file mode 100644 index 0000000..f8552e6 --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_35_0.md @@ -0,0 +1,53 @@ +# Vendoo 1.35.0 – Abnahmebericht + +## Bestanden + +- JavaScript-/MJS-Syntax aller bestehenden und neuen Kernbestandteile +- Plattform-Architecture-Gate +- zwölf Modulmanifeste und Abhängigkeiten +- Zykluserkennung +- fehlende Capability wird blockiert +- doppelte Ressourcen-Ownership wird blockiert +- Deny-by-default-Route ohne Policy wird blockiert +- nicht berechtigter Zugriff auf registrierte Route ergibt HTTP 403 +- Event-Auslieferung und Owner-Cleanup +- Lifecycle- und Modulshutdown +- sichere Theme-Werte und verbotene Token +- RGB-Bereichsprüfung +- deterministische Token-Generierung +- Light, Dark und High Contrast vorhanden +- Safe-DOM-Vertrag ohne `innerHTML` +- API-Client-Vertrag mit CSRF und Same-Origin +- Deployment-Gate 1.35.0 +- Git-Sicherheitsgate: 365 Dateien ohne Secrets/Laufzeitdaten +- Git-Sicherheits-Regressionstest +- Git-Staging-Regressionstest +- persistenter Konfigurationsspeicher +- Shell-Installer-Simulation für Docker, NAS und VPS +- 68 Migrationseinheiten gegen frische Datenbank +- 68 Migrationseinheiten gegen bestehende Datenbank +- vorhandener Testdatensatz blieb erhalten + +## Dependency-Test + +`npm ci` wurde versucht. Die Lockdatei wurde aufgelöst, aber `better-sqlite3` konnte in der Sandbox nicht fertig gebaut werden: + +1. Prebuild-Download scheiterte wegen fehlender DNS-/Netzverbindung zu GitHub. +2. Der Fallback mit lokal vorhandenen Node-Headern begann erfolgreich zu kompilieren, wurde aber beim SQLite-C-Build durch das Ausführungslimit beendet. + +Dieser Punkt wird nicht als bestanden ausgegeben. + +## Nicht praktisch getestet + +- Windows PowerShell 5.1 und WinForms +- echter Windows-Lokalstart +- echter Docker-/NAS-/Portainer-Start +- VPS/Caddy/TLS +- Browser-End-to-End-Test +- visuelle Regression aller Seiten +- vollständiger Node-Serverstart mit nativ kompiliertem `better-sqlite3` +- GitHub Actions und GHCR-Liveausführung + +## Daten- und Kompatibilitätsaussage + +Keine Datenbank, Uploads, Backups, Operations-Daten, Benutzer, Artikel, API-Schlüssel, FLUX-Modelle oder ComfyUI-Daten wurden in das Paket aufgenommen oder gelöscht. Es gibt keine Framework- oder Datenbankmigration. diff --git a/docs/ABNAHMEBERICHT_1_36_0.md b/docs/ABNAHMEBERICHT_1_36_0.md new file mode 100644 index 0000000..ef23feb --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_36_0.md @@ -0,0 +1,32 @@ +# Abnahmebericht Vendoo 1.36.0 + +## Gegenstand + +Secure Theme Manager, Accessibility & Component Token Foundation. + +## Erfolgreich geprüft + +- JavaScript-/MJS-Syntax +- Plattform-Architektur und zwölf Modulmanifeste +- natives Theme-Modul +- 42 Design Tokens und drei integrierte Themes +- Theme-Validierung und ungültige CSS-/Script-Werte +- Deny-by-default Theme-Routen +- Benutzer-/Administrator-Policy-Trennung +- Theme-Tabellen auf frischer und bestehender Datenbank +- Git-Sicherheitsgate mit 385 geprüften Dateien und Staging-Filter +- Windows-/Docker-/NAS-/VPS-Deploymentverträge +- reproduzierbare Paketauflösung mit 151 Paketen über `npm ci --ignore-scripts` +- Release-Staging mit 384 Einzeldatei-Prüfsummen + +## Nicht als real ausgeführt gewertet + +- Browser-End-to-End-Test unter echtem Windows +- Docker-/NAS-/VPS-Livestart +- Caddy-/TLS-Livetest +- GitHub-Actions-Livelauf +- native Kompilierung/Ausführung von `better-sqlite3` in der Sandbox; `npm ci --ignore-scripts` war erfolgreich, native Installationsskripte wurden bewusst nicht ausgeführt + +## Ergebnis + +Die statische, architektonische und migrationsbezogene Abnahme ist bestanden. Die genannten plattformspezifischen Laufzeittests sind nach Installation in der Zielumgebung durchzuführen. diff --git a/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md b/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md new file mode 100644 index 0000000..4c5e376 --- /dev/null +++ b/docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md @@ -0,0 +1,33 @@ +# Vendoo 1.31.0 – Ist- und Lückenanalyse + +Stand der Prüfung: 09.07.2026 +Geprüfte Basis: `Vendoo_Feature_1_31_0_Multiuser_Security_Foundation.zip` +Verifizierte SHA-256 der Basis: `cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b` + +Die Bewertung basiert auf dem real enthaltenen Code. Paketnamen und Dokumentation wurden nicht als alleiniger Funktionsnachweis verwendet. + +| Bereich | Status in 1.31.0 | Reale Fundstelle | Risiko/Lücke | Änderung in 1.32.0 | +|---|---|---|---|---| +| Versionen | bestätigt | `package.json`, Lockfile, UI-Build und Manifest = 1.31.0 | keine | auf 1.32.0 synchronisiert | +| Rollen/RBAC | bestätigt | `lib/security.mjs`, globaler `apiPermissionGuard` | einzelne Sonderrouten bleiben zusätzlich manuell zu beobachten | unverändert erhalten | +| Sessions/Login-Schutz | bestätigt, Setup-Ausnahme riskant | `lib/auth.mjs`, gehashte Tokens, Sperrlogik, Widerruf; `requireAuth` übersprang im Setup-Modus alle APIs | vor erstem Admin waren geschützte API-Routen unnötig offen; kein Offline-Notfallreset für verlorenen letzten Admin | API im Setup-Modus gesperrt; Notfallreset als Folgepunkt dokumentiert | +| CSRF/Cookies | vorhanden | Double-Submit-Cookie und Header, `HttpOnly`, dynamisches `Secure`, `SameSite=lax` | Cookie-Modus nicht konfigurierbar; Proxyannahme nur boolesch/ein Hop | Cookie-Modus konfigurierbar, Proxy-Hops/Scopes unterstützt | +| CORS/Origin | vorhanden, unvollständig | Host-/Origin-Middleware in `server.mjs` | jede Browser-Extension-Origin wurde pauschal akzeptiert | in Produktion nur konkrete Extension-Origins, unsicherer Opt-in separat | +| Audit/Locks | bestätigt | Audit-Log, `resource_locks`, Adminansicht | keine kritische Deployment-Lücke | erhalten | +| Health | teilweise | `/healthz` liefert Prozessstatus | keine Readiness-, DB- oder Schreibpfadprüfung | `/readyz` mit DB- und Volume-Schreibtest | +| Shutdown | fehlt | direkter `app.listen()` ohne Signalbehandlung | WAL/Jobs können beim Containerstopp hart beendet werden | SIGTERM/SIGINT, Server-Close, WAL-Checkpoint, DB-Close | +| Datenbankpfad | unvollständig | fest `db/vendoo.db` im Codeordner | Container-Image und Nutzerdaten nicht sauber trennbar | `VENDOO_DB_PATH` und zentrale Laufzeitpfade | +| Uploadpfad | unvollständig | fest `uploads/` im Codeordner | keine getrennte Persistenz; öffentlich statisch erreichbar | konfigurierbarer Pfad, Zugriff hinter Anmeldung | +| Uploadprüfung | Sicherheitsrisiko | Dateiendung **oder** `image/*` genügte | manipulierbarer MIME-Typ konnte beliebige Endungen umgehen | Endung **und** Bild-MIME erforderlich; Größenlimit bleibt aktiv | +| Backups | bestätigt, aber codegebunden | verifizierte ZIPs mit DB/Uploads in `operations-center.mjs` | Pfade fest an Projektroot; Live-Volume-Kopie nicht dokumentiert | separate Backup-/Operations-Volumes und sichere Stop/Backup/Start-Anleitung | +| Restore/Update | vorhanden, gestuft | Operations Center und `setup.ps1` | Container darf Anwendungscode nicht in-place überschreiben | Docker-Update nur über neues Image; Windows-Update bleibt erhalten | +| Docker/Compose | fehlt | keine entsprechenden Dateien | kein reproduzierbarer NAS-/Produktionsbetrieb | Dockerfile, Compose, Healthcheck, non-root, read-only, Volumes | +| Reverse Proxy | teilweise | Host/Origin, `PUBLIC_BASE_URL`, `trust proxy` | kein fertiger Produktionsleitfaden | HTTPS-/Proxy-Dokumentation und sichere Standardbindung | +| FLUX/ComfyUI | getrennt im Konzept | externer URL-Endpunkt; lokaler Windows-Installer | Bootstrap schreibt in Projektordner und passt nicht zu read-only Container | Container startet standardmäßig ohne FLUX; externer Dienst bleibt optional | +| Browser-Erweiterungen | real vorhanden | Chrome/Edge/Firefox/Safari-Strukturen und Pakete | Remote-Kopplung nutzt weiterhin Login-Cookie statt widerrufbarem Extension-Token | CORS gehärtet; Tokenkopplung bleibt geplanter 1.34.0-Slice | +| UI-Begriffe/Kernbereiche | weitgehend bestätigt | Navigation, Artikel, Galerie, SmartCopy, Publish und Erweiterungen im realen HTML/JS | vollständige Browser-End-to-End-Abnahme nicht statisch beweisbar | keine unnötige UI-Neuentwicklung in diesem Deployment-Slice | +| VTO | entfernt | kein aktiver FASHN-/VTO-Dienst; freier FLUX-Workflow ohne `LoadImage` | historische Dokumente enthalten weiterhin Kontext | keine VTO-Rückkehr | + +## Ergebnis + +1.31.0 besitzt eine echte Mehrbenutzer- und Sicherheitsgrundlage, war aber noch kein sauber containerisierbarer Produktionsstand. Die Hauptlücken lagen in der festen Kopplung von Code und Nutzerdaten, fehlender Readiness, fehlendem geordneten Shutdown, fehlenden Containerdateien sowie zu breiten Extension-Origin- und Uploadregeln. diff --git a/docs/BRANDING.md b/docs/BRANDING.md new file mode 100644 index 0000000..2aafd18 --- /dev/null +++ b/docs/BRANDING.md @@ -0,0 +1,23 @@ +# Vendoo Branding + +Die Anwendung verwendet ab Version 1.6.2 ein gemeinsames Vendoo-Branding für Web-App, Login, mobilen Foto-Upload und Browser-Erweiterungen. + +## Web-App + +Assets liegen unter `public/brand/`: + +- `logo/` – horizontales Logo, transparente und zugeschnittene Varianten +- `icons/` – App- und Mobilgrößen von 16 bis 512 Pixel +- `favicons/` – ICO, PNG-Favicons, Apple Touch Icon, Android-Icons und Webmanifest + +## Browser-Erweiterungen + +Chrome, Edge und Firefox verwenden dieselbe Icon-Familie in 16, 32, 48 und 128 Pixeln. Popup, Browser-Toolbar, Vinted Assistant und minimierter Assistant verwenden das neue Markenzeichen. + +## Farben + +- Vendoo Orange: `#E63B17` +- Graphit: `#22201D` +- Warmes Off-White: `#FAF6F2` + +Die Proportionen und Farben der bereitgestellten Assets sollen nicht manuell verändert werden. diff --git a/docs/FULL_LISTING_EDITOR_2026.md b/docs/FULL_LISTING_EDITOR_2026.md new file mode 100644 index 0000000..f9cea38 --- /dev/null +++ b/docs/FULL_LISTING_EDITOR_2026.md @@ -0,0 +1,29 @@ +# Vendoo Full Listing Editor 2026 + +## Ziel + +Bereits gespeicherte Artikel werden nicht mehr in einem reduzierten Detailformular, sondern in einem vollständigen Arbeitsbereich bearbeitet. Generator und Listing-Editor verwenden dieselbe Rich-Text- und HTML-Logik. + +## Funktionsumfang + +- vollständige Bearbeitung aller Listing-Felder +- Plattform, AI-Provider, Sprache, Status, SKU und Lagerort +- Titel, Tags, Verkäufernotizen und sämtliche erkannten Attribute +- Preis und Kategorie +- Foto hinzufügen, entfernen, sortieren und als Cover festlegen +- vorhandene Fotos mit Helligkeit, Kontrast, Sättigung, Rotation, Spiegelung und Crop bearbeiten +- WYSIWYG und HTML-Code gleichzeitig sichtbar +- Änderungen im WYSIWYG erscheinen sofort im HTML-Code +- Änderungen im HTML-Code werden in den WYSIWYG-Editor zurückgeführt +- separate sandboxed Plattformvorschau +- Editor-HTML und Plattform-HTML separat kopierbar + +## Datenmodell + +`listings.description_html` speichert das bereinigte Rich-Text-HTML. `listings.description` bleibt als Klartext-Fallback und für Plattformen beziehungsweise Exporte erhalten. + +Die neue Spalte wird über das bestehende `MIGRATIONS`-Array in `lib/db.mjs` ergänzt. + +## Sicherheit + +HTML wird auf unterstützte Formatierungs-Tags reduziert. Skripte, eingebettete Frames, Formulare, Event-Handler und gefährliche URLs werden entfernt. Vorschauen werden in einem `sandbox`-iframe dargestellt. diff --git a/docs/GENERATOR_STUDIO_2026.md b/docs/GENERATOR_STUDIO_2026.md new file mode 100644 index 0000000..720bc55 --- /dev/null +++ b/docs/GENERATOR_STUDIO_2026.md @@ -0,0 +1,61 @@ +# Vendoo Listing Studio 2026 + +## Ziel + +Der Listing-Generator ist kein Formularstapel mehr, sondern ein zusammenhängender Studio-Arbeitsplatz mit drei klaren Zonen: + +1. Fotos & Medien +2. AI-Konfiguration +3. Vorschau & Bearbeitung + +Die bestehende Vanilla-JS-, Express- und SQLite-Architektur bleibt erhalten. + +## Fotos & Medien + +- großes Cover mit vollständiger Bilddarstellung über `object-fit: contain` +- Klick auf ein Vorschaubild setzt es als Cover +- Drag & Drop sortiert die Bildreihenfolge +- maximal zehn Bilder pro Listing +- sichtbarer Foto-Check mit Qualitätsindikatoren +- bestehender Bildeditor, Hintergrundentfernung und Wasserzeichen bleiben integriert + +## AI-Konfiguration + +- Plattform, Provider und Modell +- Sprache und Anzahl der Varianten +- Vorlagenauswahl +- Verkäufernotizen mit Zeichenzähler +- Vorlagen übernehmen Plattform, Sprache, Hinweise und Standard-Tags +- global hinterlegte Verkäuferhinweise, Vorlagenhinweise und Artikelhinweise werden kontrolliert zusammengeführt + +## Vorschau & Bearbeitung + +- permanenter, kontextbezogener Ergebnisbereich +- Live-Zähler für Titel, Beschreibung und Tags +- editierbare erkannte Attribute +- Kategorie-Picker, Preis und Lagerort +- dynamische Gebührenvorschau +- nachvollziehbarer Qualitätswert +- HTML- und Plattformvorschau als optional aufklappbarer Bereich +- Speichern und Kopieren ohne Fensterwechsel + +## API-Erweiterung + +`POST /api/generate` akzeptiert zusätzlich: + +```json +{ + "template_id": 1, + "seller_notes": "Artikelspezifische Hinweise" +} +``` + +Standard-Tags der gewählten Vorlage werden dedupliziert mit den AI-Tags zusammengeführt. + +## Qualitätsregeln + +- keine neuen Frameworks +- keine neuen Datenbankspalten +- bestehende IDs und Generatorfunktionen bleiben kompatibel +- alle Farben und Zustände nutzen die Vendoo-CSS-Variablen +- Dark Mode und responsive Darstellung sind Bestandteil des Slices diff --git a/docs/GITHUB_DEPLOYMENT_1_34_0.md b/docs/GITHUB_DEPLOYMENT_1_34_0.md new file mode 100644 index 0000000..0bd1863 --- /dev/null +++ b/docs/GITHUB_DEPLOYMENT_1_34_0.md @@ -0,0 +1,76 @@ +# Vendoo 1.34.0 – GitHub Deployment + +## Repository + +- Repository: `Masterluke77/vendoo` +- Sichtbarkeit: privat +- Stabiler Branch: `main` +- Entwicklungsbranch: `develop` +- Erstimport-Branch: `release/1.34.0-initial-import` + +## Kontrollierter Erstimport + +Unter Windows wird `GitHub-Deploy-Assistent.bat` gestartet. Der Assistent: + +1. prüft beziehungsweise installiert Git für Windows über `winget`, +2. klont die vorhandene GitHub-Baseline in einen temporären Arbeitsordner, +3. verwendet oder erstellt `release/1.34.0-initial-import`, +4. kopiert nur den bereinigten Vendoo-Quellstand, +5. blockiert `.env`, Datenbanken, Uploads, Backups, Logs, Laufzeitdaten und Modelle, +6. führt nach Möglichkeit `tools/verify-git-safety.mjs` aus, +7. zeigt alle Änderungen vor dem Commit, +8. verlangt zur Freigabe die exakte Eingabe `PUSH`, +9. pusht ausschließlich den Release-Branch, +10. öffnet den vorbereiteten Pull-Request-Vergleich im Browser. + +Der Assistent löscht oder verändert keine produktiven Vendoo-Daten. Der temporäre Git-Arbeitsordner wird nach Abschluss entfernt. + +## Pull Request und Merge + +Der Pull Request wird gegen `main` erstellt. Vor dem Merge müssen die GitHub-Actions-Prüfungen erfolgreich sein: + +- reproduzierbare Installation über `npm ci`, +- Git-Sicherheitsgate, +- JavaScript-/MJS-Syntax, +- Deployment-Gate 1.34.0, +- Datenbankmigrationstest, +- Compose-Prüfung für lokal, NAS und VPS, +- Docker-Testbuild. + +Empfohlen ist ein Squash-Merge. Erst danach wird ein GitHub Release `v1.34.0` veröffentlicht. + +## Release-Automation + +Beim Veröffentlichen eines GitHub Releases: + +- wird ein bereinigtes Release-Staging erzeugt, +- werden Einzeldatei-SHA-256-Prüfsummen erzeugt, +- wird `Vendoo_1.34.0.zip` gebaut, +- wird eine SHA-256-Datei für das ZIP erzeugt, +- werden beide Dateien an das GitHub Release angehängt, +- wird ein Multi-Arch-Container für `linux/amd64` und `linux/arm64` gebaut, +- wird das Image als `ghcr.io/masterluke77/vendoo` in die GitHub Container Registry veröffentlicht, +- wird der veröffentlichte Image-Digest mit einer GitHub-Provenienz-Attestierung versehen. + +Das Release-ZIP wird nicht dauerhaft in die Git-Historie committed. + +## Secrets + +Produktive Secrets gehören ausschließlich in: + +- die lokale `.env`, die der Installer automatisch erzeugt, +- den persistenten Runtime-Konfigurationsspeicher von Vendoo, +- gegebenenfalls geschützte GitHub Environments für spätere Deployments. + +Nicht zulässig sind Secrets in Quellcode, Issues, Pull Requests, Workflow-Ausgaben oder Release-Paketen. + +## Späteres Produktionsdeployment + +Ein automatisches Deployment auf NAS oder VPS wird erst freigeschaltet, wenn diese Gates praktisch bestätigt sind: + +- Backup und Restore auf dem Zielsystem, +- Readiness nach Update, +- Rollback auf die vorherige Container-Version, +- TLS und Domainprüfung, +- getrennte Staging- und Produktionsumgebung, +- manuelle Freigabe des GitHub Environments `production`. diff --git a/docs/GITHUB_IMPORT_STATUS.md b/docs/GITHUB_IMPORT_STATUS.md new file mode 100644 index 0000000..e9cb68d --- /dev/null +++ b/docs/GITHUB_IMPORT_STATUS.md @@ -0,0 +1,10 @@ +# Vendoo GitHub-Importstatus + +- Zielrelease: `1.36.0` +- Importbranch: `release/1.36.0-theme-manager` +- Zielbranch: `main` +- Draft Pull Request: `#9` + +Der vollständige Quellstand wird über `GitHub-Deploy-Assistent.bat` beziehungsweise `scripts/github-deploy.sh` übertragen. Vor dem Merge müssen Git-Sicherheitsgate, Theme-Manager-Gate, Architektur-Gate, Datenbankmigrationen und GitHub Actions erfolgreich sein. + +Nicht nach GitHub gelangen dürfen `.env`, Datenbanken, Uploads, Backups, Logs, Operations-/Update-Artefakte oder FLUX-/ComfyUI-Modelle. diff --git a/docs/GITHUB_IMPORT_STATUS_1_36_0.md b/docs/GITHUB_IMPORT_STATUS_1_36_0.md deleted file mode 100644 index 0b140ac..0000000 --- a/docs/GITHUB_IMPORT_STATUS_1_36_0.md +++ /dev/null @@ -1,9 +0,0 @@ -# Vendoo 1.36.0 – GitHub-Importstatus - -- Importbranch: `release/1.36.0-theme-manager` -- Zielbranch: `main` -- Status: vorbereitet - -Dieser Branch ist für Vendoo 1.36.0 – Secure Theme Manager, Accessibility & Component Token Foundation reserviert. - -Der vollständige Quellstand wird ausschließlich über den Vendoo GitHub-Deploy-Assistenten übertragen. Vor dem Merge müssen Git-Sicherheitsgate, Theme-Manager-Gate, Architektur-Gate, Datenbankmigrationen, vollständiger Diff und GitHub Actions erfolgreich sein. diff --git a/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md b/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md new file mode 100644 index 0000000..0883103 --- /dev/null +++ b/docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md @@ -0,0 +1,20 @@ +# Vendoo 1.23.1 – Editor-/Vinted-Hotfix + +## Behobene Fehler + +1. Der Advanced Image Editor war nur über ein kleines Stift-Symbol erreichbar und konnte bei alten Browser-Assets scheinbar vollständig fehlen. +2. Die Vinted-Auswahl für Zustand, Marke, Größe oder Farbe konnte durch einen zu breiten DOM-Fallback das Titelfeld als erstes Eingabefeld auswählen und überschreiben. + +## Umsetzung + +- Sichtbarer Button **Advanced Editor öffnen** im Generator. +- Versionsparameter für `app.js` und `style.css`. +- `Cache-Control: no-store` für HTML, JavaScript und CSS. +- Build-Kennung 1.23.1 im HTML und beim Browserstart. +- Strikte, labelgebundene Feldauflösung in allen Chrome-, Edge- und Firefox-Paketen. +- Zusätzlicher Titelfeldschutz für Choice-Felder. +- Extension-Version 1.6.1. + +## Nutzeraktion nach Installation + +Die Browser-Erweiterung muss in der Erweiterungsverwaltung einmal über **Neu laden** aktualisiert werden. Danach die Vinted-Verkaufsseite vollständig neu laden. diff --git a/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md b/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md new file mode 100644 index 0000000..b417a4f --- /dev/null +++ b/docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md @@ -0,0 +1,23 @@ +# Vendoo 1.23.2 – FLUX-/Editor-Hotfix + +## FLUX Studio + +- `System & ComfyUI` befindet sich unterhalb von `Prompt & Auftrag`. +- Die angeheftete Bildvorschau enthält keine darunterliegende Systemkarte mehr und kann dadurch die Bildaktionen nicht überdecken. +- Das komplette Job-Center kann ein- oder ausgeklappt werden. +- Jeder Auftrag besitzt eine kompakte Kopfzeile und einen einzeln ein-/ausklappbaren Detailbereich. +- Aktive Aufträge werden beim ersten Laden geöffnet; terminale Aufträge bleiben kompakt. + +## Advanced Image Editor + +- Hintergrundentfernung ist als Editor-Operation integriert. +- Wasserzeichen unterstützt Text, Deckkraft sowie oben/unten links/rechts und Mitte. +- Beide Werkzeuge werden nicht destruktiv als neue Version gespeichert. +- Die bisherigen separaten Buttons wurden entfernt. +- Die Werkzeuge funktionieren in Generator, Listing-Editor und FLUX Studio. + +## Unveränderte Schutzregeln + +- Kein VTO/FASHN. +- Keine produktiven Daten oder Modelle im Paket. +- Vendoo Link bleibt 1.6.1; der Vinted-Feldschutz wird nicht zurückgenommen. diff --git a/docs/HOTFIX_STUDIO_FLOW_DETAIL.md b/docs/HOTFIX_STUDIO_FLOW_DETAIL.md new file mode 100644 index 0000000..6194ec5 --- /dev/null +++ b/docs/HOTFIX_STUDIO_FLOW_DETAIL.md @@ -0,0 +1,16 @@ +# Hotfix – Studio Flow Detailnavigation + +## Fehler + +Der Button **Prüfen & bearbeiten** lud die Listing-Detailansicht, ohne zuvor den Listings-Tab sichtbar zu schalten. Der Editor wurde dadurch in einem ausgeblendeten Bereich aufgebaut. + +## Korrektur + +`showDetail(id)` prüft nun zentral, ob der Listings-/Historie-Tab aktiv ist. Falls nicht, wird dieser vor dem Laden des Listings aktiviert. Damit funktionieren Detailaufrufe aus: + +- Studio Flow +- Attention Queue +- Listings-Tabelle +- weiteren internen Bereichen + +Zusätzlich werden ungültige Listing-IDs abgefangen. diff --git a/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md b/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md new file mode 100644 index 0000000..fa951a0 --- /dev/null +++ b/docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md @@ -0,0 +1,134 @@ +# Vendoo 1.32.0 – Installation, Update und Prüfung + +## A. Windows-Update von 1.31.0 + +1. Vendoo sowie offene Setup-, Node- und FLUX-Fenster schließen. +2. Das Release-ZIP vollständig in einen temporären Ordner entpacken. +3. Im entpackten Paket den Ordner `vendoo` öffnen. +4. Den **Inhalt** dieses Ordners nach `W:\ChatGPT\Vendoo` kopieren. +5. Windows-Abfrage **Dateien im Ziel ersetzen** bestätigen. +6. `W:\ChatGPT\Vendoo` vorher **nicht löschen, leeren oder umbenennen**. +7. `.env`, `db\vendoo.db`, `uploads`, `backups`, `operations`, lokale FLUX-Modelle und ComfyUI-Daten nicht löschen. +8. `W:\ChatGPT\Vendoo\setup.bat` starten. +9. Menüpunkt **3 – Update** ausführen. +10. Danach Menüpunkt **2 – Starten** ausführen. +11. `http://127.0.0.1:8124/healthz` und `http://127.0.0.1:8124/readyz` prüfen. + +Windows benötigt die neuen Pfadvariablen nicht. Ohne Eintrag bleiben DB, Uploads und Backups exakt an ihren bisherigen Orten. + +## B. Docker-Neuinstallation + +1. Release entpacken und in den gewünschten Server-/NAS-Ordner wechseln. +2. `.env.example` als `.env` kopieren. +3. Einen starken Einrichtungs-Code erzeugen, mindestens 32 zufällige Zeichen, und als `VENDOO_SETUP_TOKEN` setzen. Beispiele: + +```bash +openssl rand -hex 32 +``` + +PowerShell: + +```powershell +[Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLower() +``` + +Die `.env` anschließend nur für den Administrator lesbar machen (`chmod 600 .env` auf Linux/NAS). +4. Für lokalen Test `PUBLIC_BASE_URL` zunächst leer lassen. Für Produktion die echte HTTPS-URL und Host-/Origin-Allowlist setzen. +5. Starten: + +```bash +docker compose config +docker compose build --pull +docker compose up -d +``` + +6. Zustand prüfen: + +```bash +docker compose ps +docker compose logs --tail=200 vendoo +``` + +7. Lokal `http://127.0.0.1:8124/readyz` öffnen. Erwartet wird HTTP 200 und `"ok": true`. +8. Vendoo öffnen, ersten Administrator anlegen und dabei den `VENDOO_SETUP_TOKEN` als Einrichtungs-Code eingeben. +9. Danach Reverse Proxy und HTTPS konfigurieren. Port 8124 nicht direkt aus dem Internet veröffentlichen. +10. Testbackup erstellen und Prüfsumme kontrollieren. + +## C. Portainer/NAS + +### Empfohlen: Named Volumes + +`compose.yaml` direkt als Stack verwenden. Portainer legt fünf persistente Volumes an. Das Image läuft ohne Rootrechte. + +### Option: sichtbare NAS-Ordner + +1. Einen dedizierten Zielordner verwenden, zum Beispiel `/volume1/docker/vendoo`, und darunter anlegen: + +```text +runtime/db +runtime/uploads +runtime/backups +runtime/operations +runtime/logs +``` + +2. Diese Ordner UID/GID 1000 schreibbar machen. Den Vendoo-Container nicht als Root starten. +3. Compose mit Override starten: + +```bash +docker compose -f compose.yaml -f compose.bind-mounts.example.yaml up -d --build +``` + +Bei `Kein Schreibzugriff ... (Container-UID 1000)` keine Root-Ausführung aktivieren, sondern die Ordnerrechte korrigieren. `LOCAL_IMAGE_ENABLED` bleibt im Hauptcontainer standardmäßig `false`; ein vorhandenes FLUX/ComfyUI wird separat betrieben und über `LOCAL_IMAGE_URL` angebunden. + +## D. Docker-Update + +```bash +./scripts/docker-backup.sh +# neuen Vendoo-Code über den alten Codeordner kopieren; .env nicht ersetzen +docker compose config +docker compose build --pull +docker compose up -d --force-recreate +docker compose ps +docker compose logs --tail=200 vendoo +``` + +Anschließend `/readyz`, Login, Artikelübersicht, ein vorhandenes Bild, Speichern eines Testartikels, SmartCopy und Publish-Center ohne echte Veröffentlichung prüfen. + +### Rollback + +1. Container stoppen. +2. Vorheriges Image beziehungsweise vorherigen Code wieder verwenden. +3. Volumes nicht löschen. +4. Container starten und Readiness prüfen. +5. Nur wenn eine Datenmigration das verlangt, das zuvor erzeugte gemeinsame DB-/Upload-Backup in einen sicheren Testpfad zurückspielen und erst danach produktiv restaurieren. + +## E. Backup und Restore + +Backup mit kurzem geordnetem Stopp: + +```bash +./scripts/docker-backup.sh +``` + +Restore ersetzt die aktuell gemounteten DB-, Upload-, Backup-, Operations- und Logdaten. Das Skript prüft eine vorhandene SHA-256-Datei und erstellt vorab automatisch ein zusätzliches Sicherheitsbackup: + +```bash +./scripts/docker-restore.sh /pfad/zum/vendoo-data.tar.gz --confirm +``` + +Danach zwingend `/readyz`, Login, Benutzer, Artikel, Bilder und Backup-Liste prüfen. + +## F. Funktionsprüfung + +- Login, Logout und erneute Anmeldung +- Rollen-/Adminschutz +- Artikel laden, erstellen, bearbeiten und speichern +- Bild hochladen und erneut abrufen +- Galerie-Viewer und Zoom +- SmartCopy öffnen und Inhalte kopieren +- Publish-Center laden, ohne echte Veröffentlichung auszulösen +- `/healthz` und `/readyz` +- Containerneustart; Artikel und Bilder müssen erhalten bleiben +- Backup erzeugen und SHA-256 prüfen +- FLUX-Ausfall: Vendoo muss weiter starten und Kernfunktionen bereitstellen diff --git a/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md b/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md new file mode 100644 index 0000000..52c005f --- /dev/null +++ b/docs/INSTALLATION_1_33_0_MULTIPLATFORM.md @@ -0,0 +1,105 @@ +# Vendoo 1.33.0 – Installation und Abnahme + +## 1. Bestehende Windows-Installation aktualisieren + +1. Vendoo und FLUX schließen. +2. Release entpacken. +3. Den Inhalt von `vendoo` über den vorhandenen Projektordner kopieren. +4. Vorhandene Dateien ersetzen, den Zielordner nicht vorher leeren. +5. `.env`, Datenbank, Uploads, Backups, Operations, lokale Modelle und ComfyUI-Daten nicht löschen. +6. `setup.bat` starten. +7. **3 – Update** ausführen. +8. **2 – Starten** ausführen. + +## 2. Neue Windows-Installation + +`setup.bat` starten und unter **1 – Installieren / Installationsziel wählen** auswählen: + +- **Windows lokal:** Node.js, npm und lokale Projektordner +- **Docker Desktop:** Compose-Build, getrennte Volumes und automatischer Setup-Code +- **NAS/Portainer:** Übergabe an den Linux-/NAS-Assistenten +- **VPS/Linux:** Installation direkt auf dem Zielserver + +Der gewählte aktive Modus wird in `.vendoo-install-mode` gespeichert. Diese Datei enthält keine Geheimnisse. + +## 3. Linux, NAS oder VPS + +```bash +chmod +x setup.sh +./setup.sh +``` + +Auswahl: + +1. Docker auf diesem Linux-/Docker-Host mit Named Volumes +2. NAS/Portainer mit sichtbaren `runtime`-Bind-Mounts +3. VPS hinter HTTPS-Reverse-Proxy + +Der Assistent prüft Docker und das Compose-Plugin, erzeugt bei Bedarf `.env` sowie einen starken `VENDOO_SETUP_TOKEN`, validiert Compose und startet Vendoo. + +### NAS + +Der NAS-Modus legt folgende Ordner an: + +```text +runtime/db +runtime/uploads +runtime/backups +runtime/operations +runtime/logs +``` + +Port 8124 wird im LAN veröffentlicht. Keine direkte Internet-Portfreigabe verwenden. + +### VPS + +- echte öffentliche `https://`-URL angeben +- DNS und TLS vorab oder anschließend korrekt konfigurieren +- Reverse Proxy auf `127.0.0.1:8124` weiterleiten +- Port 8124 nicht öffentlich freigeben +- `VENDOO_TRUST_PROXY=true` nur hinter dem eigenen Proxy verwenden + +## 4. FLUX im Docker-Betrieb + +FLUX bleibt ein getrennter Dienst. Im Docker-Container wird ein Host-Dienst über folgende URL erreicht: + +```env +LOCAL_IMAGE_URL=http://host.docker.internal:8188 +``` + +`LOCAL_IMAGE_ENABLED` nur aktivieren, wenn FLUX/ComfyUI tatsächlich separat läuft und abgesichert ist. + +## 5. Abnahme + +### Deployment + +- `/healthz` liefert HTTP 200 +- `/readyz` liefert HTTP 200 und `ok: true` +- Neustart erhält Benutzer, Artikel und Bilder +- Backup wird erzeugt und hat SHA-256 +- Docker-Status zeigt `healthy` + +### FLUX Studio + +- Seite öffnen: Vorschau **Auswahl & Varianten** bleibt leer +- Historie ist sichtbar, aber kein Bild ist automatisch markiert +- Historienbild anklicken: Vorschau erscheint +- **Arbeitsbereich zurücksetzen**: Auswahl und Formular werden geleert/zurückgesetzt +- Bilder, Verlauf und Job-Center bleiben erhalten + +### Benutzerverwaltung + +- Benutzerliste laden +- Bearbeiten-Button öffnet das richtige Benutzerkonto +- Name, Rolle und Status speichern +- ungültiges Passwort verhindert die gesamte Speicherung +- gültiges Passwort wird gesetzt +- letzter aktiver Administrator kann nicht deaktiviert oder herabgestuft werden + +## 6. Rollback + +1. Vorheriges Vendoo-Paket über den Code kopieren. +2. `.env`, DB, Uploads und Volumes nicht ersetzen oder löschen. +3. Windows: `setup.bat` → Update → Starten. +4. Docker: `./setup.sh` → Aktualisieren oder vorheriges Image/Code bauen. +5. Readiness und Datenbestand prüfen. diff --git a/docs/INSTALLATION_1_34_0_ZERO_EDIT.md b/docs/INSTALLATION_1_34_0_ZERO_EDIT.md new file mode 100644 index 0000000..b374891 --- /dev/null +++ b/docs/INSTALLATION_1_34_0_ZERO_EDIT.md @@ -0,0 +1,112 @@ +# Vendoo 1.34.0 – Zero-Edit-Installation + +## Grundsatz + +Für keine unterstützte Betriebsart muss eine `.env`, Compose-Datei oder Proxy-Konfiguration manuell bearbeitet werden. Der Installer sammelt nur notwendige Angaben, validiert sie und schreibt die Konfiguration automatisch. + +AI-, SMTP- und Marktplatz-Zugangsdaten werden nach dem ersten Login unter **Vendoo → Einstellungen** gepflegt. Im Docker-/NAS-/VPS-Betrieb werden diese Werte in einem persistenten Konfigurationsvolume gespeichert und nach Neustarts wieder geladen. + +## Windows lokal + +1. `setup.bat` doppelklicken. +2. **Lokal auf diesem Windows-PC** wählen. +3. Erreichbarkeit festlegen: + - nur dieser PC, + - privates LAN. +4. Port bestätigen oder ändern. +5. optional eine LAN-Basisadresse für QR-Uploads eingeben. +6. optional FLUX aktivieren. +7. **Installieren** anklicken. +8. Erst-Admin-Code aus der Zwischenablage bei der ersten Einrichtung einfügen. + +Node.js wird geprüft. Fehlt Node.js, bietet der bestehende Installer eine automatische Installation an. + +## Windows mit Docker Desktop + +1. Docker Desktop starten. +2. `setup.bat` doppelklicken. +3. **Docker Desktop auf diesem Windows-PC** wählen. +4. Zugriff nur lokal oder im privaten LAN festlegen. +5. Port und optional QR-Basisadresse festlegen. +6. **Installieren** anklicken. + +Der Assistent erzeugt `.env`, Setup-Code und Installationsmodus automatisch. Die App bleibt standardmäßig nur auf `127.0.0.1` erreichbar. + +## Linux oder macOS mit Docker + +```sh +chmod +x setup.sh +./setup.sh +``` + +Im Menü **Docker auf Linux/macOS/Docker Desktop** wählen. Der Assistent fragt Port und FLUX-Konfiguration ab und startet anschließend Compose. + +## NAS / Portainer + +1. Vendoo-Ordner auf das NAS kopieren. +2. im Ordner `chmod +x setup.sh && ./setup.sh` ausführen. +3. **NAS / Portainer / privates LAN** wählen. +4. Port und LAN-Basisadresse eingeben. +5. Installation starten. + +Der Assistent legt automatisch folgende sichtbare Runtime-Verzeichnisse an: + +- `runtime/db` +- `runtime/uploads` +- `runtime/backups` +- `runtime/operations` +- `runtime/logs` +- `runtime/config` + +Diese Verzeichnisse werden bei Updates nicht gelöscht. + +## VPS mit automatischem HTTPS + +Voraussetzungen: + +- Domain zeigt per DNS auf den VPS, +- Ports 80 und 443 sind erreichbar, +- Docker Engine und Compose-Plugin sind installiert. + +Ablauf: + +```sh +chmod +x setup.sh +./setup.sh +``` + +Dann **VPS / öffentlicher Server mit automatischem HTTPS** wählen und den Domainnamen ohne Protokoll eingeben. Der integrierte Caddy-Stack beantragt und erneuert TLS-Zertifikate automatisch. Vendoo selbst veröffentlicht Port 8124 nicht direkt ins Internet. + +## Persistenter Konfigurationsspeicher + +Im lokalen Windows-Betrieb verwendet Vendoo weiterhin die lokale `.env`. In Docker setzt der Installer automatisch: + +```text +VENDOO_CONFIG_PATH=/app/data/config/vendoo.env +``` + +Die Eingabemasken schreiben dort ihre Werte mit restriktiven Dateirechten. Das Haupt-Dateisystem des Containers bleibt schreibgeschützt. + +## Backup und sensible Konfiguration + +Normale Docker-Backups enthalten Datenbank, Uploads, Backups, Operations und Logs. Der persistente Runtime-Konfigurationsspeicher mit API-/SMTP-/Marktplatzschlüsseln wird bewusst nicht in das normale Datenarchiv aufgenommen, damit ein weitergegebenes Backup nicht automatisch alle Zugangsdaten enthält. + +Bei einer Wiederherstellung auf einem neuen System werden Betriebswerte durch den Installationsassistenten neu erzeugt und externe Zugangsdaten anschließend über **Vendoo → Einstellungen** erneut eingetragen. Dazu muss keine Datei manuell bearbeitet werden. + +## Updates + +Im Linux-/NAS-/VPS-Assistenten erstellt **Aktualisieren** zuerst automatisch ein Sicherheitsbackup. Danach werden Image beziehungsweise Quellcontainer neu gebaut, gestartet und über `/readyz` geprüft. + +Unter Windows bleibt der bestehende Update- und Rollback-Ablauf erhalten. + +## Nicht automatisch verändert + +- Datenbank +- Uploads und Originalbilder +- Benutzer +- Artikel +- Backups +- Operations-Daten +- FLUX-Modelle +- ComfyUI-Daten +- API-Schlüssel diff --git a/docs/LISTINGS_UI_2026.md b/docs/LISTINGS_UI_2026.md new file mode 100644 index 0000000..8d3528c --- /dev/null +++ b/docs/LISTINGS_UI_2026.md @@ -0,0 +1,31 @@ +# Vendoo Listings UI 2026 + +## Ziel + +Die Listings-/Historie-Seite bildet die operative Übersicht aller Artikel. Sie folgt der freigegebenen Vendoo-Designsprache und ersetzt die alte Kartenliste durch eine dichte, bildorientierte Arbeitsliste. + +## Funktionsumfang + +- Volltextsuche über bestehende Listing-Suche +- Plattform- und Statusfilter +- Zeiträume 7, 30 und 90 Tage, aktuelles Jahr, gesamter Zeitraum und benutzerdefinierte Daten +- Spaltenkonfiguration mit Speicherung im Browser +- Seitengrößen 10, 20, 50 oder alle +- Seitennavigation mit direkten Seitenschaltflächen +- Auswahl einzelner Listings und Auswahl der aktuellen Seite +- Batch Publish, Nachbearbeiten, Preisänderung, Statusänderung, Duplizieren, Etikettendruck und Löschen +- CSV- und JSON-Export +- Direktänderung des Status pro Tabellenzeile +- Zeilenaktionen Öffnen, Duplizieren und Löschen +- Publish-Status-Badges je Plattform + +## Technische Regeln + +Die Umsetzung bleibt vollständig in der bestehenden Vanilla-Architektur: + +- Markup: `public/index.html` +- Verhalten: `public/app.js` +- Design: `public/style.css` +- keine neuen Frameworks +- keine Änderungen am Datenbankschema erforderlich +- vorhandene API-Endpunkte und CSRF-Absicherung werden weiterverwendet diff --git a/docs/PUBLISH_CENTER_2026.md b/docs/PUBLISH_CENTER_2026.md new file mode 100644 index 0000000..10dece3 --- /dev/null +++ b/docs/PUBLISH_CENTER_2026.md @@ -0,0 +1,28 @@ +# Vendoo Publish-Center 2026 + +## Ziel + +Das Publish-Center bündelt alle Arbeitsschritte zwischen fertigem Listing und Veröffentlichung in einer einzigen, kontextstabilen Oberfläche. + +## Bereiche + +- **Smart Copy:** Felder, HTML/Text, Fotos und Plattformwechsel +- **Direkt veröffentlichen:** Etsy- und eBay-API mit vorheriger Datenprüfung +- **eBay Queue:** Status, Fehler, Wiederholungen und Verarbeitung +- **Geplant:** zeitgesteuerte Veröffentlichungen mit Plattform und Zeitpunkt + +## UX-Regeln + +- keine internen Arbeitsabläufe in neuen Browserfenstern +- Produktbild, Titel und SKU bleiben im Kontext sichtbar +- Fehler und fehlende Pflichtfelder sind direkt handlungsfähig +- externe Plattformen öffnen nur dann einen neuen Tab, wenn die Plattform selbst benötigt wird +- alle Statusfarben verwenden die zentralen Vendoo-Tokens + +## Technische Grundlage + +- Vanilla JavaScript in `public/app.js` +- Markup in `public/index.html` +- Design-Tokens und responsive Komponenten in `public/style.css` +- bestehende Endpunkte für Smart Copy, Etsy, eBay Queue und Scheduling +- Session-, Rollen- und CSRF-Schutz bleiben unverändert aktiv diff --git a/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md b/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md new file mode 100644 index 0000000..6243566 --- /dev/null +++ b/docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md @@ -0,0 +1,50 @@ +# Vendoo 1.24.0 – Responsive UI, FLUX-Auftragsarchiv und Bildergalerie + +## Ziel + +Version 1.24.0 schließt konkrete Bedienungs- und Verwaltungsprobleme zwischen Slice 31 und dem späteren Batch-Image-Slice. Der Release verändert keine bestehende FLUX-Modellinstallation und führt keine Virtual-Try-on-Funktion ein. + +## Responsive Härtung + +Die bisherige Listing-Tabelle erzwang auf kleineren Ansichten eine Mindestbreite von bis zu 1080 Pixeln. Unterhalb von 860 Pixeln wird sie nun als Kartenliste dargestellt. Plattform, Provider, Preis, Status, Lagerort und Aktualisierung bleiben als beschriftete Zeilen lesbar. Benutzerdefiniert ausgeblendete Spalten bleiben auch mobil ausgeblendet. + +Zusätzlich wurden Breiten, Umbrüche und Overflow-Verhalten für Filterleisten, Batch-Aktionen, Pagination, FLUX-Job-Center, Galerie, Formulare und zentrale Arbeitsbereiche abgesichert. + +## Hintergrundentfernung + +`background_sensitivity` wird als Wert von 0 bis 100 in den nicht destruktiven Bearbeitungsparametern gespeichert. Vorschau und Sharp-Rendering verwenden dieselbe dynamische Schwellwert- und Feather-Logik. + +- niedriger Wert: konservative Entfernung, mehr Kantenschutz +- hoher Wert: aggressivere Entfernung ähnlicher Hintergrundfarben + +## FLUX-Auftragsarchiv + +Die additive Spalte `flux_prompt_jobs.archived_at` trennt aktive Historie und Archiv. Nur terminale Aufträge können archiviert werden. Laufende, wartende oder abbrechende Aufträge werden serverseitig übersprungen. + +Funktionen: + +- einzeln oder gesammelt archivieren +- einzeln oder gesammelt wiederherstellen +- einzelne oder mehrere archivierte Aufträge löschen +- gesamtes Archiv löschen +- serverseitige Pagination und Statusfilter +- erzeugte Bilder bleiben beim Löschen von Auftragsmetadaten erhalten + +## Bildergalerie + +Der neue Navigationsbereich „Bilder“ aggregiert vorhandene Dateien aus: + +- `flux_images` +- `image_edit_versions` +- `ai_model_photos` +- den Fotoverweisen aktiver Listings + +Doppelte Dateipfade werden zusammengeführt und mit mehreren Quellen-Badges dargestellt. Die Galerie bietet Raster-/Listenansicht, Suche, Quellenfilter, Favoriten, Sortierung, Pagination, Mehrfachauswahl, ZIP-Download, Lightbox, Editor-Übergabe und Generator-Übernahme. Dateien aktiver Listings werden beim Löschen geschützt. + +## Nicht enthalten + +- keine FASHN- oder VTO-Engine +- keine Änderung an FLUX-Modellen +- keine Datenbank-, Upload-, Listing- oder Schlüsselbereinigung +- keine Hintergrundjobs außerhalb des bestehenden Vendoo-Prozesses +- keine reale Windows-, GPU- oder ComfyUI-Abnahme ohne Nutzerausgaben diff --git a/docs/RELEASE_1_24_0_VERIFICATION.json b/docs/RELEASE_1_24_0_VERIFICATION.json new file mode 100644 index 0000000..89ecaaf --- /dev/null +++ b/docs/RELEASE_1_24_0_VERIFICATION.json @@ -0,0 +1,84 @@ +{ + "release": "1.24.0", + "date": "2026-07-08", + "checks": { + "javascript_and_esm_syntax": { + "status": "passed", + "files": 51 + }, + "json_validation": { + "status": "passed", + "files": 18 + }, + "html_ids": { + "status": "passed", + "total": 592, + "unique": 592, + "duplicates": 0 + }, + "css_structure": { + "status": "passed", + "opening_braces": 2471, + "closing_braces": 2471 + }, + "responsive_listing_rules": { + "status": "passed", + "mobile_card_layout": true, + "fixed_minimum_width_removed_below_1180": true, + "hidden_column_preferences_preserved": true + }, + "background_removal_sensitivity": { + "status": "passed", + "low_sensitivity_transparent_pixels": 60256, + "high_sensitivity_transparent_pixels": 80660, + "higher_value_removes_more": true + }, + "database_migration": { + "status": "passed", + "additive_column": "flux_prompt_jobs.archived_at", + "existing_flux_image_preserved": true + }, + "flux_archive_semantics": { + "status": "passed", + "terminal_jobs_archivable": true, + "active_jobs_protected": true, + "restore": true, + "single_delete": true, + "multi_delete": true, + "delete_all": true, + "generated_images_retained_by_design": true + }, + "media_library": { + "status": "passed", + "sources": ["flux", "edited", "ai", "listing"], + "search": true, + "filters": true, + "favorites": true, + "grid_and_list": true, + "pagination": true, + "multi_select": true, + "zip_download": true, + "lightbox": true, + "editor_handoff": true, + "listing_image_delete_protection": true + }, + "vinted_extension_regression_guard": { + "status": "passed", + "variants": 5, + "title_field_protection_present": true, + "extension_version_changed": false + }, + "package_sources": { + "status": "passed", + "registry": "https://registry.npmjs.org/", + "internal_openai_or_artifactory_sources": false + } + }, + "not_claimed": [ + "real Windows PowerShell 5.1 acceptance", + "real browser interaction on the user's installation", + "real NVIDIA GPU acceptance", + "real ComfyUI queue/history acceptance", + "real mapped-drive W: acceptance" + ] +} diff --git a/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md b/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md new file mode 100644 index 0000000..82ffc2a --- /dev/null +++ b/docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md @@ -0,0 +1,59 @@ +# Vendoo 1.25.0 – Responsive Detail Editor, Gallery UX, Smart Copy und FLUX Prompt Lab + +## Ziel + +Version 1.25.0 korrigiert die verbliebenen Breitenprobleme in der Listing-Bearbeitung und überarbeitet mehrere Bedienwege, die im produktiven Einsatz unruhig, zu eng oder nicht mehr erreichbar waren. Der Release baut auf 1.24.0 auf und bleibt vollständig als Drüberkopier-Update installierbar. + +## Listing-Bearbeitung + +- Der Detail-Editor reagiert per Container Query auf die tatsächlich verfügbare Inhaltsbreite und nicht nur auf die gesamte Browserbreite. +- Fotoleiste, Listing-Daten, Beschreibung, Plattformvorschau und Kontextkarten reduzieren sich kontrolliert von drei auf zwei und schließlich eine Spalte. +- Formularfelder, Quill-Werkzeugleiste, Iframes, Textbereiche, Aktionsleisten und lange Inhalte besitzen konsequente `min-width: 0`- und Umbruchregeln. +- Zusätzliche Media-Query-Fallbacks halten den Editor auch in Browsern ohne aktive Container-Query-Auswertung bedienbar. + +## Bildergalerie + +- Auswahlfelder sind als präzise eigene Checkbox-Komponente umgesetzt und liegen sauber innerhalb der Bildfläche. +- Favoriten befinden sich als separates Overlay oben rechts. +- Die große Vorschauaktion liegt als eindeutige, zentrierte Leiste unten im Bild und kollidiert nicht mit Auswahl oder Favorit. +- Titel können über zwei Zeilen laufen; Metadaten werden sauber gekürzt. +- Bearbeiten, Verwenden, Download und Löschen stehen in einem stabilen 2×2-Aktionsraster. +- Raster- und Listenansicht sowie mobile Ein- und Zweispaltenansichten bleiben erhalten. + +## Publish und Smart Copy + +- Aktualisieren verwendet einen ruhigen Ladezustand mit Text und `aria-busy`; der gesamte Button rotiert nicht mehr. +- Smart Copy öffnet wieder in einem eigenen responsiven Modal. +- Das Modal enthält Titel, Beschreibung, Tags, Metadaten, Fotos, Kopieraktionen, Plattformstart und Veröffentlichungsstatus. +- Vorheriges/nächstes Listing kann direkt im Modal gewechselt werden. +- Nach dem Markieren als veröffentlicht wird das Modal sauber geschlossen und der Publish-Bereich neu geladen. + +## FLUX Studio + +### Ergebnisse & Favoriten + +- Serverseitige Pagination mit 5, 10 oder 15 Bildern pro Seite. +- Vorherige-/Nächste-Navigation und aktuelle Seitenangabe. +- Favoritenfilter und Löschaktionen laden die korrekte Seite neu. + +### Job-Center + +- Aktive Aufträge und Archiv verwenden kompakte Seitengrößen von 5, 10 oder 15 Einträgen. +- Bestehende Archiv-, Wiederherstellungs-, Mehrfachauswahl- und Löschfunktionen bleiben erhalten. + +### Prompt Lab + +- Der bisherige einfache Prompt-Verbesserer öffnet ein eigenes Prompt Lab. +- Lokale Strukturierung funktioniert ohne API-Aufruf. +- AI-Veredelung nutzt den aktuell aktiven Text-Provider: Claude, OpenAI, OpenRouter oder Ollama. +- Profile: präzise, Produkt/Marketplace, kreativ und cinematisch. +- Optionaler Schutz der Motividentität sowie Vermeidung erfundener Texte, Logos und Wasserzeichen. +- Ergebnis, Provider, Modell und kurze AI-Hinweise werden sichtbar dargestellt. +- Der optimierte Prompt kann direkt zurück ins FLUX Studio übernommen werden. + +## Unveränderte Sperrregeln + +- Kein FASHN VTON und kein Virtual Try-on. +- FLUX bleibt ein freier Prompt-Bildgenerator. +- Keine Datenbank, Listings, Uploads, `.env`, Schlüssel, ComfyUI-Installation oder FLUX-Modelle werden entfernt. +- Keine internen Paketquellen. diff --git a/docs/RELEASE_1_25_0_VERIFICATION.json b/docs/RELEASE_1_25_0_VERIFICATION.json new file mode 100644 index 0000000..16c6ed1 --- /dev/null +++ b/docs/RELEASE_1_25_0_VERIFICATION.json @@ -0,0 +1,30 @@ +{ + "release": "1.25.0", + "static_checks": { + "javascript_esm_syntax_files": 51, + "json_files": 18, + "unique_html_ids": 612, + "duplicate_html_ids": 0, + "css_brace_depth": 0 + }, + "functional_mock_checks": { + "database_migrations": true, + "flux_slice30_mock": true, + "advanced_image_editor_render": true, + "background_sensitivity_low_vs_high": true, + "listing_editor_container_responsive": true, + "gallery_card_controls": true, + "smart_copy_modal": true, + "publish_refresh_without_whole_button_rotation": true, + "flux_history_page_sizes": [5, 10, 15], + "flux_job_page_sizes": [5, 10, 15], + "prompt_lab_providers": ["claude", "openai", "openrouter", "local"], + "vinted_title_guard_preserved": true + }, + "not_claimed_as_real_acceptance": [ + "Windows PowerShell 5.1 update on W drive", + "interactive browser acceptance with user data", + "real AI provider request", + "real NVIDIA GPU or ComfyUI render" + ] +} diff --git a/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md b/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md new file mode 100644 index 0000000..05de476 --- /dev/null +++ b/docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md @@ -0,0 +1,5 @@ +# Vendoo 1.25.1 – Adaptive Bildergalerie + +Dieser Hotfix ersetzt die bisherige Galerie-Kartenkomponente vollständig. Das Raster berechnet Kartenanzahl und Kartenbreite automatisch aus dem tatsächlich verfügbaren Platz. Die gesamte Bildfläche ist die Vorschauaktion. Auswahl und Favorit bleiben als kompakte, skalierende Overlays erhalten. Die vier Bildaktionen bilden ein responsives Aktionsdock. Die Seitennavigation ist oberhalb und unterhalb der Galerie vorhanden und erlaubt einen direkten Sprung zu jeder Seite. + +Die Umsetzung orientiert sich an etablierten Karten-, Auswahl- und Paginierungsmustern: Inhalt und Aktionen gehören zu einer klar scanbaren Karte, Auswahl wird durch Checkbox plus Oberflächenzustand angezeigt und Pagination gibt sowohl Seitengröße als auch direkte Navigation vor. diff --git a/docs/RELEASE_1_25_1_VERIFICATION.json b/docs/RELEASE_1_25_1_VERIFICATION.json new file mode 100644 index 0000000..a1f9e91 --- /dev/null +++ b/docs/RELEASE_1_25_1_VERIFICATION.json @@ -0,0 +1,34 @@ +{ + "release": "1.25.1", + "package_type": "full_overlay_update", + "checks": { + "javascript_syntax": "passed", + "json_validity": "passed", + "html_unique_ids": "passed", + "css_parse": "passed", + "gallery_static_release_gate": "passed", + "database_migrations": "passed", + "existing_database_row_preserved": "passed", + "flux_archive_semantics": "passed", + "runtime_data_excluded": "passed" + }, + "gallery": { + "adaptive_auto_fit_grid": true, + "card_container_queries": true, + "full_image_click_target": true, + "legacy_open_button_removed": true, + "adaptive_selection_overlay": true, + "adaptive_favorite_overlay": true, + "adaptive_action_dock": true, + "pagination_top_and_bottom": true, + "direct_page_jump": true, + "touch_behavior": true, + "keyboard_accessibility": true + }, + "not_claimed": [ + "real Windows update on W drive", + "interactive production browser acceptance", + "native npm regression suite in this build environment" + ], + "note": "The native npm regression suite could not be reinstalled in the build environment because external better-sqlite3 build artifacts were temporarily unreachable. This hotfix changes only frontend HTML, CSS and gallery JavaScript; syntax, static release gates and additive database migration tests passed." +} diff --git a/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md b/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md new file mode 100644 index 0000000..aef818a --- /dev/null +++ b/docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md @@ -0,0 +1,29 @@ +# Vendoo 1.26.0 – Artikel, Publish, Zoom-Galerie und Extension Center + +## Ziel + +Version 1.26.0 vereinheitlicht die Artikelbegriffe, repariert die Publish-/Smart-Copy-Wege, erweitert die Galerie um eine echte Zoom-Vorschau und führt eine zentrale Installation für Browser-Erweiterungen ein. + +## Funktionsumfang + +- **Neuer Artikel** direkt unter Dashboard; **Artikel** statt Listings +- **Artikel generieren** statt Listing generieren +- Publish lädt robuste Artikeldaten und verwendet im Direkt-Workspace den richtigen Container +- Smart Copy als Popup mit vollständiger zentraler Vorschau +- Galerie-Lightbox mit 10–400 %, Fit, 100 %, Mausrad und Drag-Panning +- lokale Lucide-basierte SVG-Icons statt uneinheitlicher Symbolzeichen +- aktualisierte Gebührenhinweise für private Verkäufer und Etsy-Shop-Kalkulation +- Extensions-Seite mit aktiver Browsererkennung, Ordneröffnung, Pfadkopie und ZIP-Download +- Safari Web Extension als Quelle mit Xcode-Konvertierungsskript + +## Schutzregeln + +Das Update enthält keine produktive `.env`, Datenbank, Listings, Uploads, API-Schlüssel, Logs, FLUX-Modelle oder `node_modules`. Bestehende Nutzerdaten werden nicht gelöscht. + +## Noch real zu prüfen + +- Installation und Browsercache auf dem Windows-Zielsystem +- Publish mit den produktiven Artikeln +- Smart Copy in den realen Plattformformularen +- finale Browserbestätigung der Extensions +- Safari-Konvertierung und Signierung auf einem Mac mit Xcode diff --git a/docs/RELEASE_1_26_0_VERIFICATION.json b/docs/RELEASE_1_26_0_VERIFICATION.json new file mode 100644 index 0000000..d59f9fc --- /dev/null +++ b/docs/RELEASE_1_26_0_VERIFICATION.json @@ -0,0 +1,36 @@ +{ + "release": "1.26.0", + "date": "2026-07-08", + "status": "static-gates-passed", + "scope": [ + "article-navigation", + "publish-hardening", + "smart-copy-modal", + "media-zoom", + "fee-model", + "extension-center", + "safari-source" + ], + "real_acceptance_pending": [ + "Windows update", + "productive Publish data", + "browser extension confirmation", + "Safari Xcode conversion" + ], + "javascript_esm_files": 59, + "json_files": 22, + "html_ids": 621, + "duplicate_html_ids": 0, + "css_rule_blocks": 2792, + "extension_packages_verified": 4, + "database_migration_blocks": 43, + "existing_database_row_preserved": true, + "fee_test_price_eur": 100, + "fee_results": { + "ebay_private": 0, + "vinted_seller": 0, + "kleinanzeigen_private": 0, + "etsy_estimate": 10.8 + }, + "native_runtime_test": "not_run_dependency_download_unavailable" +} diff --git a/docs/RELEASE_1_26_1_STABILITY_GATE.md b/docs/RELEASE_1_26_1_STABILITY_GATE.md new file mode 100644 index 0000000..dd5f2fe --- /dev/null +++ b/docs/RELEASE_1_26_1_STABILITY_GATE.md @@ -0,0 +1,22 @@ +# Vendoo 1.26.1 – Stabilitäts-, Responsive- und Regression-Gate + +## Ziel + +Der Release friert den Funktionsstand 1.26.0 ein und schafft eine belastbare Grundlage für Batch Image Factory, Artikel Quality Center und Publishing Hardening. Es werden keine produktiven Daten oder bestehenden Workflows entfernt. + +## Produktive Änderungen + +- `GET /healthz` liefert ausschließlich Version, Laufzeit und Zeitstempel. +- `GET /api/admin/diagnostics` prüft als Admin Datenbank, Dateisystem, Pflichtassets, Extensions, AI-Konfiguration und optional ComfyUI. +- Der Browser-API-Client nutzt Request-IDs, Zeitlimits und einen begrenzten GET-Retry. +- Netzwerkfehler werden sichtbar angezeigt und automatisch erneut geprüft. +- Die letzten 30 Clientfehler werden lokal im Browser gespeichert und können mit der Systemdiagnose kopiert werden. +- Ein `ResizeObserver` ordnet den realen Arbeitsbereich in `phone`, `narrow`, `compact`, `standard` oder `wide` ein. +- Neue globale CSS-Verträge verhindern Überbreite in allen Hauptseiten. + +## Sperrregeln + +- Keine `.env`, Datenbank, Uploads, Listings, Schlüssel, Logs oder Modelle im Releasepaket. +- Keine FASHN-/VTO-Funktion und kein `LoadImage` im freien FLUX-Workflow. +- ComfyUI ist optional; Nichterreichbarkeit ist ein Hinweis und kein Vendoo-Kernfehler. +- Diagnoseantworten geben keine API-Schlüssel oder Passwörter aus. diff --git a/docs/RELEASE_1_26_1_VERIFICATION.json b/docs/RELEASE_1_26_1_VERIFICATION.json new file mode 100644 index 0000000..04fd57b --- /dev/null +++ b/docs/RELEASE_1_26_1_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "version": "1.26.1", + "release": "Stability Responsive Regression Gate", + "static_verification": true, + "checks": { + "health_endpoint": true, + "admin_system_diagnostics": true, + "sqlite_quick_check": true, + "writable_path_probes": true, + "extension_presence_four_browsers": true, + "api_request_ids": true, + "api_timeout": true, + "get_retry_once": true, + "client_error_capture": true, + "workspace_resize_observer": true, + "ui_contract_verifier": true, + "database_migration_preservation": true, + "vto_removed": true, + "flux_workflow_without_loadimage": true + }, + "real_acceptance_pending": [ + "Windows target installation", + "interactive browser workflows", + "real ComfyUI/GPU probe", + "mapped W drive permissions" + ] +} \ No newline at end of file diff --git a/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md b/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md new file mode 100644 index 0000000..6791600 --- /dev/null +++ b/docs/RELEASE_1_30_0_OPERATIONS_CENTER.md @@ -0,0 +1,70 @@ +# Vendoo 1.30.0 – Operations Center + +Version: 1.30.0 + +## Ziel + +Vendoo erhält eine gemeinsame Betriebsgrundlage für verifizierte Sicherungen, kontrollierte Wiederherstellung, vorbereitete Offline-Updates, Rollback und Browser-Extension-Diagnose. + +## Backup und Wiederherstellung + +- vollständige Backups mit Datenbank, optionalen Uploads und optionaler `.env` +- reine Datenbank-Backups +- SQLite-Snapshot über die native Backup-Funktion +- `backup-manifest.json` mit SHA-256 je Datei +- SQLite-`integrity_check` vor Freigabe eines Backups +- manuelle Verifikation bereits erstellter Backups +- Download und kontrollierte Löschung +- automatische tägliche Sicherungen mit einstellbarer Uhrzeit und Rotation +- Wiederherstellung nur nach erfolgreicher Archivprüfung +- automatische Sicherheitssicherung vor jeder vorbereiteten Wiederherstellung +- Wiederherstellung wird ausschließlich offline über `setup.bat` Menüpunkt 11 angewendet + +## Update Center + +- installierte Version und registrierte Migrationen +- optionale Online-Prüfung über `VENDOO_UPDATE_MANIFEST_URL` +- lokaler Upload eines vollständigen Vendoo-ZIP-Pakets +- Prüfung auf gültiges Vendoo-Paket und höhere Zielversion +- Blockade von `.env`, Datenbank, Uploads, Backups, Logs, Modellen und `node_modules` im Update-Archiv +- verpflichtendes Backup-Gate vor dem Staging +- Offline-Anwendung über Setup-Menüpunkt 11 +- Code- und Laufzeit-Snapshot vor dem eigentlichen Austausch +- automatische Rückkehr zum vorherigen Stand bei einem Apply-Fehler +- manueller Rollback über Setup-Menüpunkt 12 + +## Extension-Diagnose + +Vendoo Link 1.9.0 meldet lokale Heartbeats mit: + +- Browserfamilie +- Extension-Version +- Plattformkontext +- letzter Aktivität +- Client-ID + +Der Diagnosebereich zeigt erwartete und erkannte Versionen, zuletzt aktive Clients und fehlende beziehungsweise veraltete Verbindungen. Es werden keine Seiteninhalte, Passwörter oder API-Schlüssel übertragen. + +## Datenbank + +Neue additive Tabellen: + +- `operation_backups` +- `operation_settings` +- `update_operations` +- `system_migrations` +- `extension_clients` + +Registrierte Migration: + +- `operations-center-1.30.0` + +Bestehende Artikel, Bilder, FLUX-Aufträge, Publishing-Aufträge und Einstellungen werden nicht gelöscht. + +## Sicherheitsgrenzen + +- Sicherungen mit `.env` enthalten Zugangsdaten und müssen vertraulich behandelt werden. +- Ein Restore oder Update wird nicht während des laufenden Vendoo-Servers angewendet. +- Update- und Restore-Archive werden zunächst in einen lokalen Staging-Bereich geschrieben und geprüft. +- ZIP-Pfadmanipulationen und absolute Archivpfade werden blockiert. +- Vendoo behauptet keine reale Windows-, PowerShell-, Restore-, Update- oder Rollback-Abnahme ohne Bestätigung auf dem Zielsystem. diff --git a/docs/RELEASE_1_30_0_VERIFICATION.json b/docs/RELEASE_1_30_0_VERIFICATION.json new file mode 100644 index 0000000..974920f --- /dev/null +++ b/docs/RELEASE_1_30_0_VERIFICATION.json @@ -0,0 +1,32 @@ +{ + "ok": true, + "version": "1.30.0", + "js_files": 72, + "json_files": 28, + "html_ids": 759, + "css_blocks": 3237, + "nav_targets": 16, + "extension_packages": 4, + "operations_markers": 59, + "verified_sqlite_backups": true, + "backup_manifest_sha256": true, + "automatic_rotation": true, + "restore_safety_backup": true, + "update_backup_gate": true, + "offline_apply": true, + "rollback_foundation": true, + "extension_heartbeat": true, + "zip_traversal_guard": true, + "native_runtime_test": false, + "native_runtime_test_reason": "Das produktive better-sqlite3-Modul und node_modules sind in der Build-Umgebung nicht vorhanden. Migrationen wurden mit SQLite und die JavaScript-/Archivlogik statisch beziehungsweise isoliert geprüft.", + "powershell_parser_test": false, + "powershell_parser_test_reason": "PowerShell 5.1 beziehungsweise pwsh ist in der Build-Umgebung nicht verfügbar. setup.bat enthält weiterhin den Parser-Guard für das Windows-Zielsystem.", + "real_target_acceptance": { + "windows_setup": false, + "backup_creation": false, + "restore_apply": false, + "update_apply": false, + "rollback": false, + "extension_heartbeat": false + } +} diff --git a/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md b/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md new file mode 100644 index 0000000..10fa8b6 --- /dev/null +++ b/docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md @@ -0,0 +1,28 @@ +# Vendoo 1.31.0 – Mehrbenutzer- und Server-Sicherheitsgrundlage + +## Ziel + +Version 1.31.0 führt eine belastbare Grundlage für mehrere Benutzer und einen bewusst freigegebenen LAN-/Serverbetrieb ein. Bestehende Daten werden additiv migriert. + +## Rollen + +- Administrator: Vollzugriff einschließlich Benutzer, Sitzungen, Backups, Updates und Sicherheit. +- Manager: operative Leitung ohne Benutzer- und Systemverwaltung. +- Editor: Artikel, Medien, AI/FLUX, Bildproduktion und Qualität. +- Publisher: Prüfung und Veröffentlichung bei schreibgeschützten Artikelstammdaten. +- Leser: reiner Lesezugriff. + +## Sicherheitsverträge + +- Jede geschützte API wird serverseitig auf eine Berechtigung geprüft. +- Sitzungen besitzen absolute und inaktive Laufzeiten und können widerrufen werden. +- Wiederholte Fehlanmeldungen sperren das Konto zeitweise. +- Neue Passwörter benötigen mindestens zehn Zeichen, Klein-/Großbuchstaben, Zahl und Sonderzeichen. +- Artikelbearbeitung verwendet kurzlebige persistente Sperren. +- Standard-Bind-Adresse ist `127.0.0.1`; LAN-Betrieb muss ausdrücklich aktiviert werden. +- Host, Origin, Proxy-Vertrauen und Rate Limits sind über `.env` konfigurierbar. +- Sicherheitsrelevante Aktionen und verweigerte Berechtigungen werden protokolliert. + +## Nicht Bestandteil dieser Abnahme + +Ein realer Windows-/NAS-/Reverse-Proxy-Lauf, echtes HTTPS, Firewallregeln und parallele Browser-Sitzungen müssen auf dem Zielsystem geprüft werden. diff --git a/docs/RELEASE_1_31_0_VERIFICATION.json b/docs/RELEASE_1_31_0_VERIFICATION.json new file mode 100644 index 0000000..cd21b7d --- /dev/null +++ b/docs/RELEASE_1_31_0_VERIFICATION.json @@ -0,0 +1,31 @@ +{ + "release": "Vendoo Multiuser & Security Foundation", + "version": "1.31.0", + "status": "static_and_sqlite_verified", + "verified": [ + "all JavaScript and ESM files pass node --check", + "all JSON files parse successfully", + "public/index.html contains no duplicate IDs", + "CSS braces are balanced", + "five fixed roles and permission catalog are present", + "API permission guard and rate limiter are active", + "password policy, account lockout, session expiry and revocation are present", + "persistent article edit locks are present", + "audit filtering and CSV export are present", + "default bind host is 127.0.0.1", + "host/origin/proxy/session/rate settings are documented in .env.example", + "SQLite additive migration preserves an existing listing", + "resource lock uniqueness is enforced", + "legacy database migration suite remains successful", + "free FLUX workflow contains no LoadImage", + "no active FASHN or Virtual Try-on implementation is included", + "setup.ps1 contains exactly one UTF-8 BOM" + ], + "not_claimed": [ + "real Windows PowerShell 5.1 acceptance", + "real multi-browser concurrent edit test", + "real NAS or public reverse proxy deployment", + "real HTTPS and firewall acceptance", + "native better-sqlite3 server startup in the build environment" + ] +} diff --git a/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md b/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md new file mode 100644 index 0000000..75c6d48 --- /dev/null +++ b/docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md @@ -0,0 +1,91 @@ +# Vendoo 1.32.0 – Docker, NAS & Production Deployment Foundation + +## Ziel + +Vendoo kann weiterhin unverändert lokal unter Windows betrieben werden und zusätzlich reproduzierbar in Docker beziehungsweise Portainer laufen. Anwendungscode und persistente Nutzerdaten sind getrennt. FLUX/ComfyUI bleibt ein optionaler externer Dienst und wird nicht in den Vendoo-Hauptcontainer eingebaut. + +## Neue Deployment-Bausteine + +- mehrstufiges `Dockerfile` auf Node.js 22 +- reproduzierbare Installation über `npm ci` und Lockfile +- Ausführung als nicht privilegierter Benutzer `node` (UID/GID 1000) +- `read_only`-Container, `no-new-privileges`, alle Linux-Capabilities entfernt +- getrennte persistente Volumes für DB, Uploads, Backups, Operations und Logs +- `/healthz` für Liveness und `/readyz` für DB-/Schreibpfad-Readiness +- geordneter SIGTERM-/SIGINT-Shutdown mit SQLite-WAL-Checkpoint +- zentral konfigurierbare Laufzeitpfade, bei Windows aber identische Standardpfade wie zuvor +- Erst-Admin-Schutz über einen mindestens 32 Zeichen langen `VENDOO_SETUP_TOKEN`; bis zur Einrichtung bleiben alle übrigen API-Routen gesperrt +- konfigurierbare Secure-/SameSite-Cookies +- kontrollierte Browser-Extension-Origin-Allowlist +- Uploads nur noch nach Anmeldung abrufbar +- strengere Bildannahme: zulässige Endung und Bild-MIME müssen zusammenpassen +- Docker-Backup-/Restore-Hilfsskripte für Named Volumes und NAS-Bind-Mounts sowie ein Bind-Mount-Beispiel +- begrenzte Docker-Logrotation (`10m`, fünf Dateien) + +## Laufzeitpfade + +| Variable | Docker-Standard | Windows ohne Variable | +|---|---|---| +| `VENDOO_DATA_DIR` | `/app/data` | Projektordner | +| `VENDOO_DB_PATH` | `/app/data/db/vendoo.db` | `db/vendoo.db` | +| `VENDOO_UPLOADS_DIR` | `/app/data/uploads` | `uploads/` | +| `VENDOO_BACKUP_DIR` | `/app/data/backups` | `backups/` | +| `VENDOO_OPERATIONS_DIR` | `/app/data/operations` | `operations/` | +| `VENDOO_LOG_DIR` | `/app/data/logs` | `logs/` | + +Leere Variablen fallen auf die kompatiblen Standardpfade zurück. + +## Reverse Proxy + +Der Compose-Port wird standardmäßig ausschließlich an `127.0.0.1` gebunden. Für ein NAS kann ein lokaler Reverse Proxy entweder denselben Docker-Netzwerkpfad verwenden oder der Host-Port bewusst im vertrauenswürdigen LAN bereitgestellt werden. Eine direkte öffentliche Portweiterleitung auf 8124 ist kein unterstützter Produktionsweg. + +Erforderlich hinter HTTPS: + +```env +PUBLIC_BASE_URL=https://vendoo.example.de +VENDOO_ALLOWED_HOSTS=vendoo.example.de +VENDOO_ALLOWED_ORIGINS=https://vendoo.example.de +VENDOO_TRUST_PROXY=1 +VENDOO_COOKIE_SECURE=true +VENDOO_COOKIE_SAMESITE=lax +``` + +`VENDOO_TRUST_PROXY=1` bedeutet genau einen vertrauenswürdigen Proxy-Hop. Bei anderer Topologie muss die reale Hop-Anzahl oder ein zulässiger Express-Proxybereich gesetzt werden. + +## Browser-Erweiterungen + +Im Produktionsmodus ist die pauschale Freigabe aller Extension-Origins deaktiviert. Konkrete IDs werden eingetragen, zum Beispiel: + +```env +VENDOO_EXTENSION_ORIGINS=chrome-extension://abcdefghijklmnop,moz-extension://uuid +VENDOO_ALLOW_ANY_EXTENSION_ORIGIN=false +``` + +Die bestehende Cookie-basierte Anmeldung bleibt kompatibel. Eine eigene widerrufbare Extension-Tokenkopplung ist bewusst nicht vorgezogen worden und bleibt Teil des späteren Extension Distribution Centers. + +## FLUX/ComfyUI + +Der Hauptcontainer startet standardmäßig mit: + +```env +LOCAL_IMAGE_ENABLED=false +``` + +Für einen getrennten GPU-Dienst: + +```env +LOCAL_IMAGE_ENABLED=true +LOCAL_IMAGE_URL=http://comfyui:8188 +LOCAL_IMAGE_TOKEN= +LOCAL_IMAGE_REQUIRE_TOKEN=true +LOCAL_IMAGE_ALLOW_REMOTE=true +``` + +ComfyUI darf nicht direkt öffentlich freigegeben werden. Das freie FLUX-Prompt-Workflow bleibt textbasiert; es wird kein Produktbild oder `LoadImage`-Knoten eingeschleust. + +## Updateprinzip + +- Windows: bestehender Drüberkopierablauf und `setup.bat` Menüpunkt 3 bleiben gültig. +- Docker: Nutzervolumes bleiben bestehen; nur Image/Code wird neu gebaut und der Container ersetzt. +- Vor jedem Update DB und Uploads gemeinsam sichern. +- Bei fehlgeschlagener Readiness nicht weiterarbeiten, sondern Logs und Volume-Rechte prüfen und gegebenenfalls auf das vorherige Image zurückgehen. diff --git a/docs/RELEASE_1_32_0_VERIFICATION.json b/docs/RELEASE_1_32_0_VERIFICATION.json new file mode 100644 index 0000000..16f4dc7 --- /dev/null +++ b/docs/RELEASE_1_32_0_VERIFICATION.json @@ -0,0 +1,35 @@ +{ + "schema": "vendoo-release-verification-v1", + "version": "1.32.0", + "date": "2026-07-09", + "basis_sha256": "cad2670d663c98964b7c3b4e95e5f6e475a3bcae12921339a0a84576b77d325b", + "basis_files_verified": 271, + "passed": [ + "handover-read-order", + "basis-sha256", + "basis-file-checksums", + "version-sync", + "javascript-syntax", + "shell-syntax", + "yaml-static-parse", + "fresh-db-migration", + "existing-db-migration-data-preservation", + "npm-lock-resolution-ignore-scripts", + "runtime-path-writability", + "setup-token-entrypoint", + "deployment-static-gate", + "data-hygiene", + "secret-pattern-scan", + "flux-separation-no-vto" + ], + "not_executed": { + "full-npm-ci-native-scripts": "better-sqlite3 native compilation exceeded sandbox execution limits after prebuild/network unavailability", + "server-api-smoke": "native better-sqlite3 binary unavailable", + "docker-build-compose-runtime": "docker command unavailable, exit 127", + "windows-powershell-runtime": "Windows and PowerShell unavailable", + "browser-ui-e2e": "browser runtime unavailable", + "reverse-proxy-https-live": "proxy/TLS environment unavailable", + "platform-api-publishing": "intentionally not triggered" + }, + "release_position": "package-ready-for-real-environment-acceptance" +} diff --git a/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md b/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md new file mode 100644 index 0000000..9c81102 --- /dev/null +++ b/docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md @@ -0,0 +1,26 @@ +# Vendoo 1.33.0 – Multiplatform Deployment Wizard & Flux/Admin Fixes + +Dieser Release ergänzt die Docker-/NAS-Grundlage aus 1.32.0 um native, geführte Installationswege für Windows, Docker Desktop, Linux, NAS/Portainer und VPS. Zusätzlich startet FLUX Studio neutral und die Benutzerbearbeitung wurde stabilisiert. + +## Installer-Architektur + +- Windows: `setup.bat` → `setup.ps1` +- Linux/NAS/VPS: `setup.sh` +- gemeinsamer Kern: `.env`, Compose, Volumes, Healthchecks, Backup und Restore +- keine automatische unsichere Ferninstallation auf einen VPS +- Zielsystemabhängige native Skripte statt zusätzlicher Pflicht für PowerShell 7 auf NAS/VPS + +## FLUX + +- keine automatische Auswahl aus der Bibliothek +- Reset für Prompt, Einstellungen, Vorschau und Varianten +- Verlauf und Jobs bleiben unverändert +- FLUX bleibt ein separater Text-zu-Bild-Dienst + +## Benutzerverwaltung + +- stabile Listener nach jedem Rendern der Benutzerliste +- sicher escaped Inhalte +- konsistenter Benutzer-Cache +- Vorabvalidierung für Passwortänderungen +- sanitisierte Serverantwort nach Benutzeränderung diff --git a/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md b/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md new file mode 100644 index 0000000..3f1c6d6 --- /dev/null +++ b/docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md @@ -0,0 +1,44 @@ +# Vendoo 1.34.0 – GitHub CI/CD & Zero-Edit Installation Foundation + +## Ziel + +Version 1.34.0 verbindet die vorhandene Multiplatform-Grundlage mit einer produktionsnahen GitHub- und Release-Struktur und beseitigt manuelle Konfigurationsschritte aus der Installation. + +## Architektur + +### Installation + +- Windows: WinForms-Assistent über `setup-gui.ps1`. +- Windows-Konsole: bestehendes Setup-Menü plus geführter Konfigurationspunkt. +- Linux/macOS/NAS/VPS: POSIX-kompatibler Dialogassistent über `setup.sh`. +- VPS: separater Compose-Stack mit Caddy und automatischem HTTPS. + +### Konfiguration + +- Deployment-Werte werden durch Installer in `.env` geschrieben. +- App-Eingabemasken schreiben veränderliche Schlüssel in einen persistenten Runtime-Overlay-Speicher. +- `bootstrap.mjs` lädt zuerst die Installationskonfiguration und danach den Runtime-Overlay. +- Docker verwendet ein eigenes Volume `vendoo_config`. +- Das Container-Root-Dateisystem bleibt read-only. + +### GitHub + +- CI auf Push und Pull Request. +- Release-Automation auf veröffentlichte GitHub Releases. +- GHCR-Images für AMD64 und ARM64 mit Digest-basierter Provenienz-Attestierung. +- Dependabot für npm, GitHub Actions und Docker. +- CODEOWNERS und Pull-Request-Checkliste. +- kontrollierter Erstimport über Release-Branch. + +## Sicherheitsentscheidungen + +- Kein direkter Push des unkontrollierten Projektordners nach `main`. +- Keine automatische VPS-Ferninstallation über gespeicherte SSH-Zugangsdaten. +- Kein `latest`-Deployment ohne Release-Freigabe. +- Kein Speichern von Secrets in GitHub-Workflowdateien. +- Kein Aufweichen des read-only Containers für die Einstellungsmaske. +- Keine automatische Aufnahme des Runtime-Secret-Stores in normale Backups. + +## Bewusst offen + +Ein echtes Production-CD mit SSH oder selbst gehostetem Runner ist nicht aktiviert. Es folgt erst nach praktischer NAS-/VPS-Abnahme mit Backup, Restore und Rollback. diff --git a/docs/RELEASE_1_34_0_VERIFICATION.json b/docs/RELEASE_1_34_0_VERIFICATION.json new file mode 100644 index 0000000..5056b49 --- /dev/null +++ b/docs/RELEASE_1_34_0_VERIFICATION.json @@ -0,0 +1,51 @@ +{ + "release": "1.34.0", + "date": "2026-07-09", + "base": "1.33.0", + "github": { + "repository": "Masterluke77/vendoo", + "branch": "release/1.34.0-initial-import", + "draft_pull_request": 3, + "status_commit": "c8f739afcff9024c1a7b66281484dc9cc1793394", + "full_source_push_required": true + }, + "checks": { + "javascript_mjs_syntax": "passed", + "shell_syntax": "passed", + "yaml_parse": "passed", + "git_safety": { + "status": "passed", + "files_checked": 314 + }, + "deployment_gate": "passed", + "runtime_config_store": "passed", + "shell_installer_simulation": { + "status": "passed", + "modes": [ + "docker", + "nas", + "vps" + ] + }, + "database_migrations": { + "status": "passed", + "fresh_exec_blocks": 68, + "existing_exec_blocks": 68, + "existing_row_preserved": true + }, + "release_builder": { + "status": "passed", + "files_hashed": 314 + } + }, + "not_executed": [ + "Windows WinForms and PowerShell 5.1 runtime", + "real Docker build and container start", + "Docker Desktop runtime on Windows or macOS", + "real Portainer/NAS import", + "real Caddy TLS issuance and public DNS", + "browser end-to-end test", + "native better-sqlite3 Node runtime", + "published GitHub Release and GHCR push" + ] +} diff --git a/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md b/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md new file mode 100644 index 0000000..f890c51 --- /dev/null +++ b/docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md @@ -0,0 +1,27 @@ +# Vendoo 1.34.1 – Git-Sicherheitsgate Windows Hotfix + +## Anlass + +Beim ersten praktischen Lauf des GitHub-Deploy-Assistenten unter Windows brach das Git-Sicherheitsgate mit einer pauschalen Fehlermeldung ab. Die Analyse zeigte zwei relevante Ursachen: + +1. `new URL(...).pathname` lieferte unter Windows keinen zuverlässig nutzbaren Dateisystempfad. +2. In einem geklonten Repository wurden bevorzugt nur bereits von Git verfolgte Dateien geprüft. Neu kopierte, noch unversionierte Dateien waren dadurch nicht Teil des erweiterten Secret-Scans. + +## Korrektur + +- Root-Pfade werden mit `fileURLToPath(import.meta.url)` erzeugt. +- Das Gate scannt immer den vollständigen Arbeitsbaum vor `git add`. +- Environment-Dateien wie `.env`, `.env.local`, `.env.production` und vergleichbare Varianten werden blockiert. +- Der Deploy-Assistent verlangt Node.js und überspringt den erweiterten Scan nicht mehr. +- Bei einem Treffer werden die konkreten Dateien angezeigt und zusätzlich nach `logs/github-deploy-safety-report.txt` geschrieben. +- Ein Regressionstest erzeugt absichtlich unversionierte Test-Secrets und erwartet zwingend eine Blockierung. + +## GitHub + +- Neuer Importbranch: `release/1.34.1-initial-import` +- Neuer Draft Pull Request: `#4` +- Der alte Draft Pull Request `#3` wurde geschlossen und als ersetzt markiert. + +## Datenhaltung + +Der Hotfix verändert oder löscht keine Datenbank, Uploads, Backups, `.env`, API-Schlüssel, Benutzer, Artikel, FLUX-Modelle oder ComfyUI-Daten. diff --git a/docs/RELEASE_1_34_1_VERIFICATION.json b/docs/RELEASE_1_34_1_VERIFICATION.json new file mode 100644 index 0000000..1fcb68f --- /dev/null +++ b/docs/RELEASE_1_34_1_VERIFICATION.json @@ -0,0 +1,17 @@ +{ + "release": "1.34.1", + "status": "passed_static_and_regression", + "git": { + "branch": "release/1.34.1-initial-import", + "pull_request": 4, + "supersedes_pull_request": 3 + }, + "checks": [ + "windows-safe fileURLToPath root", + "full working-tree scan", + "untracked secret regression", + "deployment gate", + "shell syntax", + "version consistency" + ] +} diff --git a/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md b/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md new file mode 100644 index 0000000..05aca1b --- /dev/null +++ b/docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md @@ -0,0 +1,47 @@ +# Vendoo 1.34.2 – Git-Sicherheitsdiagnose & gemeinsamer Staging-Builder + +## Anlass + +Beim praktischen Windows-Lauf erschien nur: + +```text +Git-Sicherheitsgate fehlgeschlagen (4): +``` + +Die Zahl `4` ist die Anzahl der gefundenen Treffer, nicht der Prozess-Exitcode. Windows PowerShell 5.1 behandelte die über stderr ausgegebenen Detailzeilen jedoch als Fehlerstrom und brach die Ausgabe nach der Überschrift ab. + +Zusätzlich verwendeten Windows und Linux unterschiedliche Kopierregeln. Lokale `.env.*`-Dateien oder Operations-Laufzeitordner konnten dadurch zunächst in das temporäre Staging gelangen und wurden erst anschließend vom Gate blockiert. + +## Lösung + +### Einheitlicher Staging-Builder + +`tools/prepare-git-staging.mjs` wird auf allen Plattformen verwendet. Er: + +- erhält ausschließlich das `.git`-Verzeichnis des temporären Klons, +- entfernt alte Dateien aus dem temporären Arbeitsbaum, +- kopiert nur freigegebene Quelldateien, +- übernimmt `.env.example` und `.env.docker.example`, +- schließt produktive `.env.*`-Dateien aus, +- schließt Operations-, Upload-, Backup-, Log-, Datenbank- und Modelllaufzeitdaten aus, +- verändert niemals den echten Vendoo-Installationsordner. + +### Vollständige Diagnose unter Windows + +Der PowerShell-Assistent startet Node über `Start-Process` und leitet stdout und stderr in getrennte temporäre Dateien um. Anschließend werden alle Zeilen kontrolliert eingelesen und angezeigt. + +Bei einem echten Treffer entsteht: + +```text +logs/github-deploy-safety-report.txt +``` + +Der Bericht wird unter Windows automatisch im Editor geöffnet. + +## Sicherheit + +Der Filter ersetzt das Sicherheitsgate nicht. Nach dem bereinigten Kopiervorgang scannt `verify-git-safety.mjs` weiterhin den vollständigen Staging-Arbeitsbaum auf eingebettete Schlüssel, Tokens, private Schlüssel, unerwartet große Dateien und verbotene Laufzeitdaten. + +## Datenhaltung + +Der Hotfix verändert oder löscht keine Datenbank, Uploads, Backups, `.env`, API-Schlüssel, Benutzer, Artikel, FLUX-Modelle oder ComfyUI-Daten. diff --git a/docs/RELEASE_1_34_2_VERIFICATION.json b/docs/RELEASE_1_34_2_VERIFICATION.json new file mode 100644 index 0000000..971c384 --- /dev/null +++ b/docs/RELEASE_1_34_2_VERIFICATION.json @@ -0,0 +1,20 @@ +{ + "release": "1.34.2", + "status": "passed_static_migration_and_regression", + "git": { + "branch": "release/1.34.2-initial-import", + "pull_request": 5, + "supersedes_pull_request": 4 + }, + "checks": [ + "shared cross-platform staging builder", + "environment and runtime staging exclusions", + "git metadata preservation", + "complete Windows stdout and stderr diagnostics", + "full working-tree secret scan", + "untracked secret regression", + "deployment gate", + "shell installer simulation", + "database migrations" + ] +} diff --git a/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md b/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md new file mode 100644 index 0000000..43d63a9 --- /dev/null +++ b/docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md @@ -0,0 +1,17 @@ +# Vendoo 1.34.3 – Update-Archiv-Ausschluss + +## Ursache + +Lokale Update- und Slice-Sicherungen unter `updates/backups/` wurden vom Staging-Builder als normale Quelldateien behandelt. Das Sicherheitsgate blockierte anschließend darin enthaltene Installationsskripte korrekt. + +## Lösung + +- `updates/` wird im gemeinsamen Node-Staging-Builder vollständig ausgeschlossen. +- Dieselbe Filterfunktion wird vom GitHub-Import und vom Release-Builder verwendet. +- `.gitignore` enthält `updates/`. +- Das Sicherheitsgate betrachtet `updates/` weiterhin als verbotenen Laufzeitpfad. +- Ein Regressionstest bildet die drei real gemeldeten Dateien exakt nach. + +## Datenwirkung + +Der lokale Ordner `updates/` wird weder gelöscht noch verändert. Er wird ausschließlich nicht nach GitHub kopiert. Datenbank, Uploads, `.env`, Backups, Operations-Daten, FLUX-Modelle und ComfyUI-Daten bleiben unverändert. diff --git a/docs/RELEASE_1_34_3_VERIFICATION.json b/docs/RELEASE_1_34_3_VERIFICATION.json new file mode 100644 index 0000000..19d1e02 --- /dev/null +++ b/docs/RELEASE_1_34_3_VERIFICATION.json @@ -0,0 +1,33 @@ +{ + "release": "1.34.3", + "date": "2026-07-09", + "github": { + "branch": "release/1.34.3-initial-import", + "draft_pull_request": 6, + "previous_pull_request_closed": 5 + }, + "checks": { + "git_staging_regression": true, + "exact_reported_paths_excluded": 3, + "active_installer_files_preserved": [ + "setup.bat", + "setup.ps1", + "scripts/install-local-flux.ps1" + ], + "git_safety_files_checked": 327, + "migration_exec_blocks_fresh": 68, + "migration_exec_blocks_existing": 68, + "existing_row_preserved": true, + "shell_installer_modes": [ + "docker", + "nas", + "vps" + ] + }, + "not_runtime_tested": [ + "Windows PowerShell 5.1 on user machine", + "real local Git push", + "Docker/Portainer/VPS live deployment", + "GitHub Actions after full source import" + ] +} diff --git a/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md b/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md new file mode 100644 index 0000000..b1699c9 --- /dev/null +++ b/docs/RELEASE_1_35_0_PLATFORM_KERNEL.md @@ -0,0 +1,64 @@ +# Vendoo 1.35.0 – Modular Platform Kernel, Design-System Contracts & Security Architecture Foundation + +## Ziel + +Version 1.35.0 stoppt den unkontrollierten weiteren Ausbau der großen Bestandsdateien und schafft eine verbindliche Plattformarchitektur. Die bestehende Anwendung bleibt funktionsfähig und wird nicht per Big-Bang ersetzt. + +## Implementiert + +### Plattformkernel + +- `app/kernel/platform-kernel.mjs` +- geordneter Start und Stop +- Lifecycle-Rollback bei fehlgeschlagenem Start +- Kernelzustand im Readiness-Check +- geordneter Kernelshutdown vor dem SQLite-Checkpoint + +### Modulsystem + +- zwölf validierte Bestandsmodule +- Manifestvertrag und JSON-Schema +- Abhängigkeitsauflösung mit Zykluserkennung +- Capability-Prüfung +- eindeutige Ressourcen-Ownership +- Start-Rollback bereits gestarteter Module +- Status `legacy-bridge` für noch nicht vollständig migrierte Bereiche + +### Sicherheit + +- Policy Engine mit Deny-by-default +- Routenregistrierung nur mit Modulbesitzer und Policy +- standardisierte Plattformfehler mit Request-ID +- geschützte Admin-Endpunkte für Kernel-, Modul- und Theme-Vertragsstatus +- Owner-basierter Event-Bus +- feste Feature-Flag-Registrierung +- isolierter Extension-Host bleibt standardmäßig deaktiviert + +### Design-System + +- 33 versionierte Design Tokens +- Light, Dark und High Contrast +- deterministische CSS- und Vertragsgenerierung +- Kompatibilitätsbrücke zu bestehenden CSS-Variablen +- Browser-Theme-Runtime mit Systempräferenz +- sichere Backend-Validierung für künftige Theme-Definitionen +- kein freies CSS, JavaScript oder externe URL-Werte + +### Frontend-Verträge + +- Frontend Module Registry mit `mount`/`unmount` +- Listener-Cleanup über `AbortController` +- Safe-DOM-Helfer auf Basis von `textContent` +- zentraler zukünftiger API-Client mit Same-Origin-Credentials, CSRF und standardisierten Fehlern + +## Neue interne Endpunkte + +Nur Benutzer mit `security.manage`: + +- `GET /api/security/platform` +- `GET /api/security/platform/modules` +- `GET /api/security/platform/theme-contract` + +## Nicht behauptet + +Version 1.35.0 ist die Foundation, nicht die vollständige Migration. `server.mjs`, `public/app.js`, `public/style.css` und `lib/db.mjs` bleiben weiterhin groß. Die Fachmodule werden ab 1.38.0 einzeln migriert. Der sichtbare Theme Manager folgt in einem eigenen Release. diff --git a/docs/RELEASE_1_35_0_VERIFICATION.json b/docs/RELEASE_1_35_0_VERIFICATION.json new file mode 100644 index 0000000..52a03ba --- /dev/null +++ b/docs/RELEASE_1_35_0_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "release": "1.35.0", + "architecture": "modular-monolith", + "modules": 12, + "design_tokens": 33, + "themes": ["light", "dark", "contrast"], + "feature_flags": 4, + "policies": 2, + "new_registered_routes": 3, + "git_branch": "release/1.35.0-platform-kernel", + "draft_pull_request": 7, + "checks": { + "syntax": true, + "architecture_gate": true, + "deployment_gate": true, + "git_safety": true, + "git_safety_regression": true, + "git_staging_regression": true, + "config_store": true, + "installer_shell_simulation": true, + "fresh_db_migration_blocks": 68, + "existing_db_migration_blocks": 68, + "existing_row_preserved": true, + "npm_ci": false, + "npm_ci_reason": "Native better-sqlite3 build could not finish in the network-restricted, time-limited sandbox." + } +} diff --git a/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md b/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md new file mode 100644 index 0000000..743a76a --- /dev/null +++ b/docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md @@ -0,0 +1,34 @@ +# Vendoo 1.36.0 – Secure Theme Manager + +## Ziel + +Version 1.36.0 migriert den Theme-Bereich als erstes Fachmodul vollständig auf den Vendoo-Plattformkernel. Die sichtbare Verwaltung ist sicher, benutzerfreundlich und ohne manuelle Dateiänderungen nutzbar. + +## Architektur + +Das Modul `vendoo.themes` besitzt eigene Services, Repository-Funktionen, Tabellen, Policies und API-Verträge. Andere Fachbereiche bleiben zunächst als Legacy Bridges registriert. + +## Datenmodell + +- `theme_profiles`: administrative, validierte Theme-Profile +- `user_theme_preferences`: persönliche Darstellung pro Benutzer + +Die Migration ist additiv. Bestehende Vendoo-Daten werden nicht verändert oder gelöscht. + +## Sicherheitsmodell + +- Deny-by-default-Routen +- Benutzer dürfen nur die eigene Präferenz ändern +- administrative Profile und Systemstandard benötigen `settings.manage` +- ausschließlich freigegebene Design Tokens +- keine freie CSS-, JavaScript-, HTML-, URL- oder Dateipfad-Eingabe +- serverseitige Wiederholungsvalidierung +- Audit-Protokoll + +## Design-System + +Der Vertrag umfasst 42 typisierte Tokens und die integrierten Themes Light, Dark und High Contrast. Komponenten-Tokens steuern unter anderem Buttons, Eingaben, Karten, Dialoge, Fokusbreite und Mindestzielgröße. + +## Kompatibilität + +Windows lokal, Docker Desktop, NAS/Portainer und VPS bleiben unterstützt. FLUX bleibt ein getrennter Dienst. Originalbilder, Datenbank, Benutzer, Artikel, Uploads und Backups werden nicht überschrieben oder gelöscht. diff --git a/docs/RELEASE_1_36_0_VERIFICATION.json b/docs/RELEASE_1_36_0_VERIFICATION.json new file mode 100644 index 0000000..896d11d --- /dev/null +++ b/docs/RELEASE_1_36_0_VERIFICATION.json @@ -0,0 +1,50 @@ +{ + "release": "1.36.0", + "name": "Secure Theme Manager, Accessibility & Component Token Foundation", + "moduleCount": 12, + "nativeModules": [ + "vendoo.themes" + ], + "legacyBridgeCount": 11, + "designTokenCount": 42, + "builtinThemes": [ + "light", + "dark", + "contrast" + ], + "gates": [ + "check", + "verify:architecture", + "verify:themes", + "verify:deployment", + "verify:git", + "verify:git-regression", + "verify:git-staging", + "verify:config", + "verify:installer-shell", + "verify:db" + ], + "runtimeTestsNotClaimed": [ + "Windows browser E2E", + "Docker/NAS/VPS live start", + "Caddy TLS", + "GitHub Actions live run", + "native better-sqlite3 install in sandbox" + ], + "gitSafetyFilesChecked": 385, + "releaseStagingFilesChecksummed": 384, + "dependencyResolution": { + "command": "npm ci --ignore-scripts", + "packagesAdded": 151, + "result": "passed" + }, + "databaseVerification": { + "freshDbExecBlocks": 68, + "existingDbExecBlocks": 68, + "existingRowPreserved": true, + "themeTables": [ + "theme_profiles", + "user_theme_preferences" + ] + } +} diff --git a/docs/SLICE_02_ACCEPTANCE.md b/docs/SLICE_02_ACCEPTANCE.md new file mode 100644 index 0000000..124fac5 --- /dev/null +++ b/docs/SLICE_02_ACCEPTANCE.md @@ -0,0 +1,29 @@ +# Slice 02 – Listings / Historie Acceptance + +## Abnahmeziele + +- [x] freigegebene Vendoo-Designsprache übernommen +- [x] bestehende Listings-API unverändert weiterverwendet +- [x] Suche, Plattform-, Status- und Datumsfilter erhalten +- [x] Zeitraumauswahl ergänzt +- [x] CSV- und JSON-Export erhalten +- [x] Einzel- und Seitenauswahl umgesetzt +- [x] Batch Status, Preis, Duplizieren, Etiketten, Publish und Löschen umgesetzt +- [x] Inline-Statusänderung pro Listing erhalten +- [x] Detailansicht weiterhin erreichbar +- [x] Pagination mit direkter Seitenauswahl umgesetzt +- [x] Spaltenkonfiguration lokal gespeichert +- [x] Dark-Mode-Tokens verwendet +- [x] keine hardcodierten produktiven Secrets enthalten + +## Technische Prüfungen + +- `node --check public/app.js` +- `node --check server.mjs` +- `node --check lib/db.mjs` +- keine doppelten HTML-IDs +- ausgeglichene CSS-Klammerstruktur + +## Runtime-Hinweis + +Ein vollständiger Runtime-Test benötigt die plattformspezifische Installation von `better-sqlite3`. Das Paket enthält bewusst kein `node_modules`; Installation erfolgt über den gehärteten Installer beziehungsweise `npm ci`. diff --git a/docs/SLICE_05_ACCEPTANCE.md b/docs/SLICE_05_ACCEPTANCE.md new file mode 100644 index 0000000..80aca05 --- /dev/null +++ b/docs/SLICE_05_ACCEPTANCE.md @@ -0,0 +1,28 @@ +# Slice 05 – Abnahme Publish-Center + +## Funktionsumfang + +- [x] Smart Copy vollständig innerhalb Vendoo +- [x] direkte Etsy-/eBay-Veröffentlichung mit Integrationsstatus +- [x] Listing-Daten vor der Veröffentlichung inline bearbeitbar +- [x] eBay Queue ohne separates Modal steuerbar +- [x] Fehler, Wiederholungen und Queue-Status sichtbar +- [x] geplante Veröffentlichungen inline verwaltbar +- [x] Batch-Auswahl aus Listings wird übernommen +- [x] Fotos werden vollständig ohne Beschnitt dargestellt +- [x] responsive Layouts für Desktop, Tablet und Mobile +- [x] Dark Mode ausschließlich über zentrale CSS-Variablen + +## Technische Prüfungen + +- [x] `node --check server.mjs` +- [x] `node --check lib/db.mjs` +- [x] `node --check public/app.js` +- [x] keine doppelten HTML-IDs +- [x] ausgeglichene CSS-Klammerstruktur +- [x] keine `.env`, Datenbank, Uploads oder `node_modules` im Paket +- [x] bestehende API-Routen, Authentifizierung und CSRF-Logik beibehalten + +## Installation + +Als Update über `setup.bat` → **3 Update** installieren. Danach Browser mit `Strg + F5` neu laden. diff --git a/docs/SLICE_06_ACCEPTANCE.md b/docs/SLICE_06_ACCEPTANCE.md new file mode 100644 index 0000000..c63ebce --- /dev/null +++ b/docs/SLICE_06_ACCEPTANCE.md @@ -0,0 +1,25 @@ +# Slice 06 – Abnahme + +## Funktionsabnahme + +- [x] Studio Flow öffnet den sichtbaren Listing-Editor. +- [x] gespeicherte Listings können vollständig bearbeitet werden. +- [x] Fotos können ergänzt, entfernt, sortiert und als Cover gesetzt werden. +- [x] der vorhandene Fotoeditor funktioniert im Generator und im Listing-Editor. +- [x] WYSIWYG-Änderungen erscheinen unmittelbar im HTML-Code. +- [x] HTML-Codeänderungen werden in den WYSIWYG-Editor zurückgeführt. +- [x] Generator und Listing-Editor rendern eine separate Plattformvorschau. +- [x] Rich-Text-HTML wird dauerhaft in `description_html` gespeichert. +- [x] Klartext bleibt in `description` als Fallback erhalten. +- [x] Plattformvorschauen laufen in sandboxed iframes. + +## Technische Abnahme + +- [x] DB-Migration für `description_html` im `MIGRATIONS`-Array. +- [x] `photos` und `tags` werden bei Updates als JSON gespeichert. +- [x] Listing-Updates verwenden eine feste Feld-Allowlist. +- [x] Client- und Server-Sanitizing für Rich-Text-HTML. +- [x] JavaScript-/ESM-Syntaxprüfung bestanden. +- [x] keine doppelten statischen HTML-IDs. +- [x] CSS-Klammerstruktur vollständig. +- [x] keine `.env`, Datenbanken, Uploads oder `node_modules` im Paket. diff --git a/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md b/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md new file mode 100644 index 0000000..939a13b --- /dev/null +++ b/docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md @@ -0,0 +1,36 @@ +# Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload + +## Browser-Erweiterungen + +- Chrome: `extensions/chrome` +- Edge: `extensions/edge` +- Firefox: `extensions/firefox` +- neues Vendoo-Link-Icon in 16/32/48/128 px +- neues Popup nach freigegebenem Mockup +- neuer Vendoo Assistant mit produktzentrierter Detailansicht und Auto-Fill-Flow + +## HTML-/Plattformvorschau + +Im Generator und Listing-Editor ist HTML nicht mehr als dauerhaft zweite Spalte sichtbar. Innerhalb der Plattformvorschau kann zwischen **Plattformvorschau** und **HTML-Code** umgeschaltet werden. WYSIWYG und HTML bleiben bidirektional synchronisiert. + +## Responsive Layout + +Abnahmepunkte: + +- Desktop ab 1440 px +- Notebook 1180–1439 px +- iPad Querformat 1024 px +- Tablet Hochformat 768 px +- Smartphone 360–560 px + +Navigation, Generator, Listing-Editor, Studio Flow, Tabellen, Modale und Kontextbereiche werden je Breakpoint neu priorisiert. + +## Mobiler Foto-Upload + +1. Nutzer erzeugt in Generator oder Listing-Editor einen QR-Code. +2. Vendoo erstellt ein kryptografisch zufälliges, gehasht gespeichertes Upload-Token. +3. Der Link ist 20 Minuten gültig und auf maximal zehn Bilder begrenzt. +4. Smartphone lädt Bilder über eine eigenständige responsive Upload-Seite hoch. +5. Desktop pollt die Sitzung und übernimmt neue Bilder automatisch. + +`PUBLIC_BASE_URL` oder eine erreichbare LAN-/HTTPS-Adresse ist erforderlich, wenn der QR-Code von einem anderen Gerät geöffnet wird. diff --git a/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md b/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md new file mode 100644 index 0000000..3ed520e --- /dev/null +++ b/docs/SLICE_08_EXTENSION_AUTOFILL_UX.md @@ -0,0 +1,33 @@ +# Slice 08 – Extension Auto-Fill & UX + +## Ziel + +Die Vinted-Erweiterung soll kompakt, hochwertig und sicher bedienbar sein. Das bloße Ausfüllen eines Formulars darf niemals automatisch einen Entwurf erzeugen oder eine Veröffentlichung auslösen. + +## Verbindliches Aktionsmodell + +1. **Nur ausfüllen** + - trägt Titel, Beschreibung, Preis, Tags und bestmöglich weitere Attribute ein + - wählt die Kategorie, sofern möglich + - speichert keinen Entwurf + - veröffentlicht nicht + - setzt keinen Publish-Status in Vendoo + +2. **Daten + Bilder ausfüllen** + - überträgt zusätzlich die Produktbilder + - speichert keinen Entwurf + - veröffentlicht nicht + +3. **Als Entwurf** und **Veröffentlichen** + - sind getrennte, explizite Abschlussaktionen + - befinden sich im Detailbereich unter einer aufklappbaren Sicherheitssektion + +## Browser + +Identische UI- und Funktionsbasis unter: + +- `extensions/chrome` +- `extensions/edge` +- `extensions/firefox` + +Version: `1.4.0` diff --git a/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md b/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md new file mode 100644 index 0000000..5e5be6a --- /dev/null +++ b/docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md @@ -0,0 +1,17 @@ +# Slice 11 – Dashboard-First & Responsive Hotfix + +## Umfang + +- Dashboard als erster Navigationspunkt und Standardansicht +- fließende KPI-Kacheln mit Auto-Fit +- responsive Sparklines und Diagramme +- ResizeObserver für Dashboard-Visualisierungen +- vollständige Produktbild-Darstellung im Studio Flow +- automatische Kartenverteilung für Desktop, Tablet und Mobile + +## Abnahme + +- keine Text-/Diagrammüberlagerung in KPI-Karten +- keine abgeschnittenen Bilder in „Bereit für den nächsten Schritt“ +- Dashboard öffnet nach dem Login zuerst +- Diagramme reagieren auf Fenster- und Sidebar-Breitenänderungen diff --git a/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md b/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md new file mode 100644 index 0000000..6e859a3 --- /dev/null +++ b/docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md @@ -0,0 +1,34 @@ +# Slice 22.1 – Setup-integrated FLUX recovery + +## Confirmed incident + +The 7-Zip process completed successfully with `Everything is Ok`, 54,878 files +and about 4,038 MB extracted. Windows PowerShell 5.1 nevertheless exposed an +empty `Process.ExitCode`, which the previous installer treated as a non-zero +failure. + +## Recovery contract + +The setup startup recovery activates only when all of these conditions match: + +1. `install-state.json` is `failed` and `can_resume=true`. +2. The stored message contains the empty 7-Zip exit-code failure and + `Everything is Ok`. +3. The 7-Zip stderr log is empty. +4. The stdout log contains the success marker. +5. The extracted portable tree contains both `ComfyUI\main.py` and + `python_embeded\python.exe`. +6. No matching installer or 7-Zip process is still active. +7. The final ComfyUI target is not already installed. + +The user starts only `setup.bat`. Setup then offers to continue automatically. +The complete extraction is reused; no repeat extraction or project reset occurs. + +## Required Windows acceptance + +- Run the provided one-click updater. +- Let the updater parser-check and back up the three affected files. +- Allow the newly launched `setup.bat` to continue the detected installation. +- Confirm the log contains `Vollständiger temporärer ComfyUI-Entpackstand wird wiederverwendet`. +- Confirm ComfyUI, Python, workflow, model and start/stop scripts are present. +- Confirm `http://127.0.0.1:8188/system_stats` is reachable after startup. diff --git a/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md b/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md new file mode 100644 index 0000000..5091ad8 --- /dev/null +++ b/docs/SLICE_22_2_DOWNLOAD_RECOVERY.md @@ -0,0 +1,23 @@ +# Vendoo Slice 22.2 – Download Recovery 1.14.4 + +## Bestätigter Fehler + +Der asynchrone BITS-Aufruf konnte unter Windows PowerShell 5.1 ein leeres Jobobjekt liefern. Dadurch wurde `Get-BitsTransfer` mit einer leeren Job-ID aufgerufen. Der anschließende `Invoke-WebRequest`-Fallback zeigte bei großen Dateien nur die PowerShell-Standardmeldung „Webanforderung wird geschrieben“ und bot keine verlässliche Fortschritts- oder Wiederaufnahmefunktion. + +## Korrektur + +- BITS und `Invoke-WebRequest` wurden für große Binärdownloads ersetzt. +- Neuer .NET-Streaming-Downloader mit HTTP-Range-Wiederaufnahme. +- Vorhandene `.part`-Dateien bleiben erhalten. +- Fortschritt mit Prozent, MB, Gesamtgröße, MB/s und Restzeit. +- Statusdatei wird alle zwei Sekunden aktualisiert. +- Konsolen-Heartbeat alle zehn Sekunden. +- Bis zu fünf automatische Wiederholungen nach Netzwerkunterbrechungen. +- `setup.bat` erkennt einen unterbrochenen Modell-Download und setzt ihn ohne Menüeingabe fort. +- Der Ein-Klick-Updater beendet den alten Installerprozess, entfernt veraltete BITS-Jobs, sichert die Altdateien und startet Vendoo automatisch neu. + +## Geänderte Dateien + +- `setup.bat` +- `setup.ps1` +- `scripts/install-local-flux.ps1` diff --git a/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md b/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md new file mode 100644 index 0000000..8e1c6c3 --- /dev/null +++ b/docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md @@ -0,0 +1,22 @@ +# Vendoo Slice 22.3 – PowerShell 5.1 Parser Fix 1.14.5 + +## Fehlerbild + +Windows PowerShell 5.1 meldete beim Start von `scripts/install-local-flux.ps1`: + +```text +InvalidVariableReferenceWithDrive +Ungültiger Variablenverweis. Nach ":" folgte kein Zeichen ... +``` + +## Ursache + +Innerhalb zweier interpolierter Zeichenfolgen stand `$Label:`. PowerShell 5.1 interpretiert den Doppelpunkt direkt nach einem Variablennamen als mögliche Scope-/Laufwerksangabe. + +## Korrektur + +Beide Vorkommen wurden auf `${Label}:` geändert. Damit ist der Variablenname eindeutig begrenzt. + +## Updateweg + +Den vollständigen ZIP-Inhalt über den bestehenden Vendoo-Ordner kopieren und gleichnamige Dateien ersetzen. Den Zielordner nicht vorher löschen. Anschließend `setup.bat` doppelklicken. Vorhandene `.part`-Downloads bleiben erhalten und werden fortgesetzt. diff --git a/docs/SLICE_22_3_SHA256.json b/docs/SLICE_22_3_SHA256.json new file mode 100644 index 0000000..fb7571f --- /dev/null +++ b/docs/SLICE_22_3_SHA256.json @@ -0,0 +1,6 @@ +{ + "setup.ps1": "bf5fc9f0a56e7d01f16419427f4bd3af1043c9b67e091fe595542ca49bbc164b", + "scripts/install-local-flux.ps1": "67488baffc35a5b6af4b3379468226a202611fff1ee63358992014a5e4de80fd", + "CHANGELOG.md": "8292be2828a0d1340318ba19b40dec55b8eeccf96171b903066a6fc9ac15e674", + "docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md": "95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269" +} diff --git a/docs/SLICE_22_4_SHA256.json b/docs/SLICE_22_4_SHA256.json new file mode 100644 index 0000000..2f56d4a --- /dev/null +++ b/docs/SLICE_22_4_SHA256.json @@ -0,0 +1,9 @@ +{ + "scripts/install-local-flux.ps1": "8ded6e1e3a311f36eb1f5be5165ee3b56b88b05b42b29826ce218cdc3b2ee848", + "lib/ai-local-images.mjs": "1439c483d7562a4fb16197edb2221e93c34ce1814e261ef1fe8c6fc26b9e767c", + "local-ai/workflows/vendoo_flux_schnell_api.json": "5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265", + "package.json": "ab72cdee8fbd1e4d8e743a19c90d2343beb7c960dc4a8642f2a94fc5ae4a3abb", + "CHANGELOG.md": "7c9e9de1e943052a910a6dd64e7dc56c427908a6e5542bd87cf00c5f90586f21", + "docs/SLICE_22_4_UTF8_BOM_FIX.md": "d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807", + "docs/SLICE_22_4_VERIFICATION.json": "f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe" +} diff --git a/docs/SLICE_22_4_UTF8_BOM_FIX.md b/docs/SLICE_22_4_UTF8_BOM_FIX.md new file mode 100644 index 0000000..66594f5 --- /dev/null +++ b/docs/SLICE_22_4_UTF8_BOM_FIX.md @@ -0,0 +1,26 @@ +# Slice 22.4 – UTF-8-BOM-Fix für lokalen FLUX-Workflow + +## Reale Fehlerursache + +Windows PowerShell 5.1 schreibt bei `Set-Content -Encoding utf8` standardmäßig +ein UTF-8-BOM. Dadurch begann die Workflow-Datei unsichtbar mit `U+FEFF` vor +dem eigentlichen `{`. + +Node.js `JSON.parse()` meldete deshalb: + +```text +Unexpected token '', "{ ..." is not valid JSON +``` + +## Korrektur + +- zentrale `Write-Utf8NoBom`-Funktion im FLUX-Installer +- Workflow-JSON und Installationsstatus werden ohne BOM geschrieben +- Laufzeit entfernt defensiv ein vorhandenes BOM vor `JSON.parse()` +- mitgelieferte Workflow-Datei ist BOM-frei + +## Benutzerablauf + +Vollständigen ZIP-Stand entpacken und über den bestehenden Vendoo-Ordner +kopieren. Gleichnamige Dateien ersetzen, den Zielordner nicht löschen. +Danach `setup.bat` starten. diff --git a/docs/SLICE_22_4_VERIFICATION.json b/docs/SLICE_22_4_VERIFICATION.json new file mode 100644 index 0000000..f5e4128 --- /dev/null +++ b/docs/SLICE_22_4_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "version": "1.14.6", + "json_files_validated": 10, + "json_files_with_bom": [], + "node_syntax_checks": { + "lib/ai-local-images.mjs": { + "returncode": 0, + "stderr": "" + }, + "server.mjs": { + "returncode": 0, + "stderr": "" + }, + "public/app.js": { + "returncode": 0, + "stderr": "" + } + }, + "changed_files": [ + "scripts/install-local-flux.ps1", + "lib/ai-local-images.mjs", + "local-ai/workflows/vendoo_flux_schnell_api.json", + "package.json", + "CHANGELOG.md", + "docs/SLICE_22_4_UTF8_BOM_FIX.md" + ] +} diff --git a/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md b/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md new file mode 100644 index 0000000..e24c5ed --- /dev/null +++ b/docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md @@ -0,0 +1,52 @@ +# Slice 23 – FLUX Product Reference, Live Progress & Settings Command Center + +## Reale Ausgangsfehler + +1. Der lokale ComfyUI-Workflow war Text-to-Image. `__SOURCE_IMAGE__` wurde ersetzt, + aber von keinem Workflow-Knoten verwendet. Das Produktfoto hatte deshalb keinen + technischen Einfluss auf das Ergebnis. +2. AI-Bildjobs setzten beim Start einmal `progress = 10` und erst am Ende `100`. +3. Eine alte oder nicht lesbare `install-state.json` konnte `unknown` liefern, + obwohl alle realen Installationsdateien vorhanden waren. +4. Die Einstellungen bestanden aus einer langen, ungruppierten Liste. + +## Umsetzung + +### Produktreferenz + +- Upload des ersten Produktfotos über ComfyUI `/upload/image` +- dynamische Workflow-Erweiterung mit `LoadImage`, `ImageScale` und `VAEEncode` +- KSampler nutzt den referenzbasierten Latent-Input +- modusabhängige Denoise-Stärke plus einstellbare Referenztreue +- 768 × 768 als schneller Standard; 1024 × 1024 als optionale Qualitätsstufe +- verstärkte Prompt-Regeln für Farbe, Kapuze, Reißverschluss, Taschen, Logos, + Drucke, Nähte und sichtbare Gebrauchsspuren + +### Fortschritt + +- additive DB-Migration `ai_model_jobs.progress_message` +- Fortschrittsmeldungen für Vorprüfung, Providerwahl, Referenzupload, + ComfyUI-Rendering, Sicherheitsprüfung und Speicherung +- sichtbare Fortschrittskarte im Generator und Listing-Editor + +### Status und Administration + +- BOM-sicheres Lesen der Installationsstatusdatei +- reale Kernchecks haben Vorrang vor veraltetem Status +- eindeutige Zustände: running, stopped, disabled, not-installed +- sicherer PowerShell-Uninstaller mit automatischem Stoppen und Entfernen der + Windows-Autostartaufgabe +- Downloads bleiben standardmäßig erhalten + +### Einstellungen + +- Command-Center-Layout mit vier Bereichen +- prominenter FLUX-Master-Schalter +- Live-Gesundheitskarten +- technische Pfade und Logs nur noch in aufklappbaren erweiterten Bereichen + +## Grenzen + +Image-to-Image-Pipeline bindet das Produktfoto real ein und verbessert die +Artikelähnlichkeit deutlich, kann aber bei komplexen Logos oder extremen +Perspektivwechseln keine pixelgenaue Reproduktion garantieren. diff --git a/docs/SLICE_23_VERIFICATION.json b/docs/SLICE_23_VERIFICATION.json new file mode 100644 index 0000000..75931ee --- /dev/null +++ b/docs/SLICE_23_VERIFICATION.json @@ -0,0 +1,62 @@ +{ + "version": "1.15.0", + "slice": "23", + "date": "2026-07-07", + "node_syntax": "passed for all .js and .mjs files", + "json_validation": { + "files_checked": 12, + "bom_files": [] + }, + "html_validation": { + "duplicate_ids": [], + "required_settings_ids_present": true + }, + "powershell_static_validation": { + "unsafe_variable_colon_patterns": [], + "uninstaller_encoding": "UTF-8 with BOM for Windows PowerShell 5.1" + }, + "local_flux_mock_integration": { + "source_upload": true, + "load_image_node": true, + "image_scale_node": true, + "vae_encode_node": true, + "sampler_uses_reference_latent": true, + "reference_denoise_at_70_percent": 0.765, + "progress_events_observed": 7, + "final_progress": 100, + "default_resolution": 768 + }, + "limitations": [ + "No real Windows/PowerShell 5.1 runtime or physical GPU was available in this build environment. The user must validate the final ComfyUI render on the Vendoo Windows system." + ], + "changed_files": [ + "lib/ai-local-images.mjs", + "lib/ai-model-photos.mjs", + "lib/ai-model-jobs.mjs", + "lib/db.mjs", + "server.mjs", + "public/index.html", + "public/app.js", + "public/style.css", + "scripts/uninstall-local-flux.ps1", + "package.json", + "CHANGELOG.md", + "FEATURES.md", + "docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md" + ], + "sha256": { + "lib/ai-local-images.mjs": "a792c3d744f51352fd75e65c1dc29e8c3034b2526e7dfac2e0f21f2390696c5a", + "lib/ai-model-photos.mjs": "af58af492d95f4a2d5d6d8a6cd8d7eca93af90de2ef6401db5c6f713c525a886", + "lib/ai-model-jobs.mjs": "70e4046e7f47ff26d44fb8628851238c631711e910b617c8c5110ab0e0641c1a", + "lib/db.mjs": "71bcc63191c9cfabe2115a05c6a1d208fee1456015fab20845b3cd1fd2de07cd", + "server.mjs": "a577174d5ee885201406ca263a6489b2528aa24243b1c32c4b17b74faf5b725e", + "public/index.html": "3d718a6553e63484949e82c6d8544d979e3a7ff6e9361e513fe47e0f362b3972", + "public/app.js": "814c2ca5442bcab9cbfc7eac4883c34f1d003d0899f4d2eda25c0c9420175bb7", + "public/style.css": "8d2f4b26a4974164e8f0e2a2248121529a4ac5ef351f49cf6536134602c94b9d", + "scripts/uninstall-local-flux.ps1": "3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd", + "package.json": "f7a483b9f9796655e6266d5614d2789ce579592578ee4b4e6d18ce6b35cb31ce", + "CHANGELOG.md": "d55c9a2dd3715e4a597e583bef96d1974d62c1d0a0101888ab1ec5a4aea45ca1", + "FEATURES.md": "61c711f50b1e25e7388de922ef6ef5eef4322470882a4579c7fefd46582c4f9d", + "docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md": "6d7033671600bad73ae3f7fca8da95ce3db17ca16d88c80a16db8551d9d3d550" + } +} diff --git a/docs/SLICE_26_9_VERIFICATION.json b/docs/SLICE_26_9_VERIFICATION.json new file mode 100644 index 0000000..cbec368 --- /dev/null +++ b/docs/SLICE_26_9_VERIFICATION.json @@ -0,0 +1,21 @@ +{ + "version": "1.18.9", + "slice": "26.9", + "file_count": 226, + "checks": { + "javascript_esm_syntax": true, + "python_syntax": true, + "json_validation": true, + "html_duplicate_ids": false, + "powershell_utf8_bom": true, + "batch_utf8_bom": false, + "encoding_scan": true, + "public_npm_registry_only": true, + "fresh_npm_ci_ignore_scripts": true, + "runtime_private_data_excluded": true, + "vto_utf8_mode": true, + "human_parser_file_probe": true, + "native_exit_code_wrapper": true, + "non_admin_user_autostart": true + } +} diff --git a/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md b/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md new file mode 100644 index 0000000..2479a76 --- /dev/null +++ b/docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md @@ -0,0 +1,22 @@ +# Slice 28.1 – FLUX Runtime Health-Check + +Version: 1.20.1 + +## Ziel + +ComfyUI darf nicht mehr nach wenigen Sekunden als gestoppt gelten, während Python, Alembic und die ComfyUI-Datenbank noch initialisieren. + +## Umsetzung + +- Startskript wartet bis zu 180 Sekunden auf `http://127.0.0.1:8188/system_stats`. +- PID-, Status- und Diagnoseinformationen unter `logs/local-flux-runtime-state.json`. +- Laufzeitprotokolle: + - `logs/local-flux-runtime.log` + - `logs/local-flux-runtime-error.log` +- ANSI-Sequenzen werden bei der Anzeige entfernt. +- Ein vorzeitig beendeter Prozess wird sofort als Fehler erkannt. +- Für das Vendoo Prompt-Studio werden Custom Nodes deaktiviert, da der Workflow nur Standard-Nodes nutzt. + +## Bestandsschutz + +ComfyUI, FLUX-Modell, Downloads, Datenbank, Uploads und Einstellungen bleiben erhalten. diff --git a/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md b/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md new file mode 100644 index 0000000..f0bd17c --- /dev/null +++ b/docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md @@ -0,0 +1,31 @@ +# Slice 28 – FLUX Studio und vollständige VTO-Bereinigung + +## Ziel + +Vendoo verwendet keinen lokalen Virtual-Try-on-Stack mehr. FLUX/ComfyUI bleibt als eigenständiger, textbasierter Bildgenerator erhalten. + +## Entfernt + +- lokaler Python-/Torch-/ONNX-Bilddienst +- Modelbibliothek und zugehörige API-Routen +- alte Installations-, Start-, Stop- und Autostartpfade +- zugehörige Einstellungen, Datenbanktabelle und Benutzeroberfläche +- historische aktive Workflow-Pfade für Try-on + +Beim ersten Setup-Lauf werden vorhandene Runtime- und Autostartreste entfernt. Eigene frühere Modelbilder werden nicht vernichtet, sondern nach `uploads/ai-generated/legacy-models` verschoben. + +## FLUX Studio + +- eigener Hauptmenüpunkt +- freier Prompt +- Stil, Format, Seed und Schritte +- Live-Fortschritt +- Vorschau und Download +- persistente lokale Bibliothek +- Übernahme in den Listing-Generator +- nachträgliche Bearbeitung mit Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung +- bearbeitete Ausgaben als neue Version statt stiller Überschreibung + +## Bestandsschutz + +Nicht gelöscht werden Datenbank, Listings, normale Uploads, `.env`, API-Schlüssel, lokale FLUX-/ComfyUI-Installation, Modelle oder Downloads. diff --git a/docs/SLICE_28_VERIFICATION.json b/docs/SLICE_28_VERIFICATION.json new file mode 100644 index 0000000..4153a09 --- /dev/null +++ b/docs/SLICE_28_VERIFICATION.json @@ -0,0 +1,24 @@ +{ + "slice": "28", + "version": "1.20.0", + "generated_at": "2026-07-08", + "checks": { + "javascript_esm_syntax": "passed", + "json_validation": "passed", + "html_duplicate_ids": "passed", + "css_brace_balance": "passed", + "sqlite_schema_and_cleanup_migration": "passed", + "public_npm_lock_registry": "passed", + "npm_lock_resolution_ignore_scripts": "passed_151_packages", + "flux_mock_prompt_workflow": "passed", + "flux_prompt_has_no_loadimage": "passed", + "powershell_bom_policy": "passed", + "encoding_scan": "passed", + "runtime_and_user_data_excluded": "passed" + }, + "notes": [ + "Native better-sqlite3 npm build could not finish in the Linux build environment because DNS access to GitHub/nodejs.org was unavailable.", + "Windows PowerShell 5.1 runtime acceptance remains to be performed on the target Windows system." + ], + "source_file_count": 191 +} diff --git a/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md b/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md new file mode 100644 index 0000000..0ab1b07 --- /dev/null +++ b/docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md @@ -0,0 +1,12 @@ +# Slice 29 – FLUX Quality & Settings Tabs + +Version 1.21.0 optimiert FLUX.1-schnell für vier Schritte, ergänzt strukturierte Promptführung, Performanceprofile und eine horizontale Tab-Navigation in den Einstellungen. + +## Profile +- Schnell: 768 px, 4 Schritte +- Ausgewogen: bis 1024 px, 4 Schritte +- Detail: 1024 px, 6 Schritte +- Eigene Werte: freie Auswahl + +## Runtime +Auto wählt bei NVIDIA-GPUs ab 12 GB VRAM das schnelle Profil, unter 8 GB das Low-VRAM-Profil und sonst Ausgewogen. Unsupported ComfyUI-Flags werden nicht erzwungen. diff --git a/docs/SLICE_29_VERIFICATION.json b/docs/SLICE_29_VERIFICATION.json new file mode 100644 index 0000000..31e9091 --- /dev/null +++ b/docs/SLICE_29_VERIFICATION.json @@ -0,0 +1,24 @@ +{ + "version": "1.21.0", + "slice": "29", + "checks": { + "javascript_esm_files": 46, + "javascript_syntax": "passed", + "json_files": 14, + "json_validation": "passed", + "html_unique_ids": 484, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "mock_comfyui_prompt_flow": "passed", + "prompt_mode_negative_prompt_empty": true, + "flux_schnell_default_steps": 4, + "settings_top_tabs": "implemented" + }, + "limitations": [ + "Real Windows/NVIDIA performance depends on the installed GPU, VRAM, ComfyUI build and drivers.", + "The build environment did not run the Windows PowerShell parser." + ] +} diff --git a/docs/SLICE_30_FLUX_STUDIO_PRO.md b/docs/SLICE_30_FLUX_STUDIO_PRO.md new file mode 100644 index 0000000..57c1b8b --- /dev/null +++ b/docs/SLICE_30_FLUX_STUDIO_PRO.md @@ -0,0 +1,68 @@ +# Slice 30 – FLUX Studio Pro + +**Version:** 1.22.0 +**Basis:** 1.21.0 / Slice 29 +**Ziel:** produktives, persistentes Prompt-Bildstudio mit ComfyUI-Jobsteuerung + +## Funktionsumfang + +### Varianten und Seeds + +Ein Auftrag erzeugt exakt 1, 2 oder 4 Varianten. Jede Variante ist ein eigener persistenter Datensatz und besitzt einen eigenen Seed. Ohne Seed Lock werden nach der ersten Variante zufällige, untereinander eindeutige Seeds erzeugt. Mit Seed Lock wird aus dem Basis-Seed eine reproduzierbare Folge `Basis-Seed + Variantenindex` gebildet. Ein wiederholter Auftrag übernimmt die vollständige Seed-Liste des Quellauftrags. + +### Job-Center + +`flux_prompt_jobs` speichert den Auftrag; `flux_prompt_job_variants` speichert jede Variante. Das Job-Center zeigt: + +- Auftrag und Status +- Prompt und final strukturierten Prompt +- Profil, Stil, Format, Schritte und Promptmodus +- Varianten-Seeds und ComfyUI-Prompt-IDs +- reale Bearbeitungsphase und Laufzeit +- Ergebnis, Fehler und Abbruchstatus + +Unterstützte Zustände sind `queued`, `running`, `cancelling`, `completed`, `completed_with_errors`, `failed` und `cancelled`. + +### ComfyUI-Anbindung + +Vendoo verwendet: + +- `POST /prompt` zur Übergabe +- `GET /queue` für laufende und wartende ComfyUI-Aufträge +- `POST /queue` zum gezielten Entfernen wartender Prompt-IDs +- `POST /interrupt` zum Abbruch des aktuell laufenden Auftrags +- `GET /history` und `GET /history/{prompt_id}` für Historie und Fertigstellung +- `GET /system_stats` für System-, GPU- und VRAM-Informationen +- `GET /view` für das fertige Bild + +Fehler einzelner Diagnose-Endpunkte werden getrennt angezeigt und machen die übrigen Systemdaten nicht unbrauchbar. + +### Neustart-Wiederaufnahme + +Beim Vendoo-Start werden persistente Varianten geprüft: + +- `running` mit gespeicherter `prompt_id`: Vendoo setzt die Überwachung über die ComfyUI-Historie fort. +- `running` ohne `prompt_id`: Variante wird sicher erneut in die Vendoo-Warteschlange gestellt. +- `queued`: bleibt wartend und wird durch den Worker übernommen. + +Es werden keine vorhandenen Ergebnisbilder, Jobs oder Nutzerdaten gelöscht. + +### Fortschritt + +Der FLUX-Studio-Pro-Pfad übergibt keine geschätzten Prozentwerte. Angezeigt werden ausschließlich beobachtbare Phasen wie Vorbereitung, Übergabe, Rendering, Download, Fertigstellung, Abbruch oder Fehler sowie die tatsächlich vergangene Zeit. + +### Bibliothek + +Ergebnisbilder speichern Auftrag, Variante, Seed, Profil, Stil, Format, Schritte, Promptmodus und Laufzeit. Favoriten sind persistent filterbar. „Ähnlich erneut erzeugen“ übernimmt Prompt und Parameter und startet bewusst mit einem neuen Seed. + +## Datenbankmigration + +Bestehende `flux_images` werden nur um optionale Felder ergänzt. Neue Tabellen werden mit `CREATE TABLE IF NOT EXISTS` angelegt. Bestehende Datenbankzeilen bleiben erhalten. Das Update enthält keine Datenbankdatei. + +## Unveränderte Sperrregeln + +- FLUX Studio ist ein reiner Text-Prompt-Generator. +- FASHN VTON und Virtual Try-on bleiben entfernt. +- Keine neue VTO-Engine. +- Keine produktive `.env`, Datenbank, Uploads, Logs, Schlüssel, ComfyUI-Binaries oder Modelle im Paket. +- Keine internen OpenAI-/Artifactory-Paketquellen. diff --git a/docs/SLICE_30_VERIFICATION.json b/docs/SLICE_30_VERIFICATION.json new file mode 100644 index 0000000..131c7e4 --- /dev/null +++ b/docs/SLICE_30_VERIFICATION.json @@ -0,0 +1,81 @@ +{ + "version": "1.22.0", + "slice": "30", + "generated_at": "2026-07-08", + "basis": "1.21.0 / Slice 29", + "checks": { + "javascript_esm_files": 47, + "javascript_syntax": "passed", + "json_files": 16, + "json_validation": "passed", + "html_unique_ids": 501, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "runtime_private_files_excluded": "passed", + "active_vto_deliverables_absent": "passed", + "prompt_workflow_load_image": false, + "persistent_job_tables": [ + "flux_prompt_jobs", + "flux_prompt_job_variants" + ], + "variant_counts": [ + 1, + 2, + 4 + ], + "phase_progress_without_estimated_percent": "implemented", + "comfyui_mock": { + "ok": true, + "prompt_id": "slice30-prompt-id", + "phases": [ + "preparing", + "submitting", + "submitted", + "rendering", + "downloading", + "completed" + ], + "queue_summary": { + "running": 1, + "pending": 1, + "total": 2 + }, + "gpu": "Mock GPU", + "interrupt": true, + "targeted_pending_cancel": true, + "targeted_running_cancel": true, + "cancelled_before_submit": true, + "prompt_submissions": 1, + "load_image_in_prompt_workflow": false + }, + "database_upgrade_simulation": { + "ok": true, + "fresh_db_exec_blocks": 38, + "existing_db_exec_blocks": 28, + "existing_row_preserved": true, + "tables": [ + "flux_images", + "flux_prompt_jobs", + "flux_prompt_job_variants" + ], + "new_flux_image_columns": [ + "favorite", + "job_id", + "parameters_json", + "prompt_mode", + "variant_index" + ] + } + }, + "limitations": [ + "Windows PowerShell 5.1 parser was not available in the build environment; setup.ps1 encoding and static package rules were checked only.", + "npm ci could not be completed in the isolated build environment because the public registry lookup returned EAI_AGAIN; the lockfile and .npmrc contain only registry.npmjs.org.", + "No real Windows setup update was executed.", + "No real NVIDIA GPU, VRAM, FLUX model or live ComfyUI render was available.", + "Live cancellation and restart recovery must be confirmed from user output on the target system." + ], + "source_package_sha256": "1a3edd9e9e3b0c4b8cf6c8566b6cb22a9133fdf599c139f980314a93ffca90b1" +} diff --git a/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md b/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md new file mode 100644 index 0000000..ac3ea52 --- /dev/null +++ b/docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md @@ -0,0 +1,72 @@ +# Slice 31 – Advanced Image Editor + +**Version:** 1.23.0 +**Basis:** Vendoo 1.22.0 / Slice 30 + +## Ziel + +Der bisherige einfache Fotoeditor wurde durch einen nicht destruktiven Editor für Produktbilder und FLUX-Ergebnisse ersetzt. Das Original bleibt dauerhaft erhalten; jede Speicherung erzeugt eine neue Datei und eine nachvollziehbare Version. + +## Funktionen + +### Nicht destruktive Bearbeitung + +- Originaldatei wird nie überschrieben +- persistente Versionenkette in `image_edit_versions` +- ältere Versionen als neue Bearbeitungsbasis auswählbar +- Original direkt wiederherstellbar +- neue Versionen bleiben nach Vendoo-Neustart nachvollziehbar + +### Arbeitsablauf + +- Undo und Redo innerhalb der aktuellen Sitzung +- Bearbeitungshistorie mit vollständigen Parametersnapshots +- Vorher-/Nachher-Ansicht +- Anzeige von Quell- und Ausgabeabmessungen + +### Geometrie + +- freier Zuschnitt +- Seitenverhältnisse Original, 1:1, 4:5, 3:4 und 16:9 +- 90-Grad-Drehung links/rechts +- horizontales und vertikales Spiegeln +- Begradigung von -15 bis +15 Grad + +### Licht und Farbe + +- Belichtung +- Helligkeit +- Kontrast +- Lichter +- Schatten +- Weißabgleich/Temperatur +- Sättigung +- Schärfe + +### Export + +- JPEG, WebP oder PNG +- Qualitätsstufe 40–100 für JPEG/WebP +- Originalgröße oder maximale Zielgröße 2048, 1600, 1200 Pixel +- benutzerdefinierte Breite und Höhe mit proportionalem `inside`-Fit +- kein Hochskalieren über die vorhandene Auflösung + +## Technik + +- Vorschau im Browser mit Vanilla JavaScript und Canvas +- produktives Rendering serverseitig mit dem bereits vorhandenen `sharp` +- normalisierte Zuschnittkoordinaten für auflösungsunabhängige Exporte +- additive SQLite-Migration ohne Änderung bestehender Listings oder Bilder +- Pfadvalidierung beschränkt Bearbeitungen strikt auf `uploads/` +- bei fehlgeschlagener Versionsprotokollierung wird die neu erzeugte Datei wieder entfernt + +## Bewusste Abgrenzung + +Nicht enthalten und nicht als Platzhalter dargestellt: + +- Hintergrundentfernung +- Inpainting +- Outpainting +- Maskeneditor + +Diese Funktionen bleiben einem späteren, technisch separat ausgewählten und abgenommenen Slice vorbehalten. diff --git a/docs/SLICE_31_VERIFICATION.json b/docs/SLICE_31_VERIFICATION.json new file mode 100644 index 0000000..3893dd0 --- /dev/null +++ b/docs/SLICE_31_VERIFICATION.json @@ -0,0 +1,137 @@ +{ + "version": "1.23.0", + "slice": "31", + "generated_at": "2026-07-08", + "basis": "1.22.0 / Slice 30", + "checks": { + "javascript_esm_files": 49, + "javascript_syntax": "passed", + "json_validation": "passed", + "html_unique_ids": 537, + "html_duplicate_ids": 0, + "css_braces": "balanced", + "powershell_utf8_bom": "passed", + "batch_without_bom": "passed", + "public_npm_registry": "passed", + "runtime_private_files_excluded": "passed", + "active_vto_deliverables_absent": "passed", + "advanced_editor_controls": [ + "undo", + "redo", + "session_history", + "restore_original", + "before_after", + "free_crop", + "ratio_original", + "ratio_1_1", + "ratio_4_5", + "ratio_3_4", + "ratio_16_9", + "rotate_left", + "rotate_right", + "flip_horizontal", + "flip_vertical", + "straighten", + "exposure", + "brightness", + "contrast", + "highlights", + "shadows", + "temperature", + "saturation", + "sharpness", + "jpeg_export", + "webp_export", + "png_export", + "quality", + "target_size" + ], + "persistent_version_table": "image_edit_versions", + "image_editor_render_test": { + "ok": true, + "source": { + "width": 960, + "height": 720, + "format": "png" + }, + "output": { + "width": 544, + "height": 640, + "format": "webp", + "filename": "verification/3e3792cd-714f-4cf3-82a8-1d06fe3803af.webp" + }, + "operations": { + "undo_redo_state_shape": true, + "crop": { + "x": 0.08, + "y": 0.1, + "width": 0.84, + "height": 0.76 + }, + "rotation": 90, + "straighten": 2.5, + "tone_controls": [ + "exposure", + "brightness", + "contrast", + "highlights", + "shadows", + "temperature", + "saturation", + "sharpness" + ], + "resize": { + "width": 640, + "height": 640 + }, + "quality": 84 + }, + "non_destructive": true, + "version_recorded": true, + "traversal_blocked": true + }, + "database_upgrade_simulation": { + "ok": true, + "fresh_db_exec_blocks": 41, + "existing_db_exec_blocks": 41, + "existing_row_preserved": true, + "tables": [ + "flux_images", + "flux_prompt_jobs", + "flux_prompt_job_variants", + "image_edit_versions" + ], + "new_flux_image_columns": [ + "favorite", + "job_id", + "parameters_json", + "prompt_mode", + "variant_index" + ], + "image_edit_version_columns": [ + "context", + "created_at", + "file_size", + "format", + "height", + "operations_json", + "output_path", + "quality", + "root_path", + "source_path", + "user_id", + "width" + ] + }, + "source_package_sha256": "5fdda17c3edce978b37de6ee73ae305e016b6c96cc406b75b783cd80a708b0c1", + "json_files": 17 + }, + "limitations": [ + "No real Windows setup update was executed in the build environment.", + "Windows PowerShell 5.1 was not available; setup.ps1 encoding and static package rules were checked only.", + "No interactive browser acceptance with the user's real product or FLUX images was possible.", + "The image rendering pipeline was tested with Sharp in the Linux build environment; final Windows rendering must be confirmed on the target system.", + "npm ci could not be completed because the isolated environment could not resolve GitHub/Node.js downloads for the native better-sqlite3 dependency; Slice 31 adds no new npm dependency.", + "No live NVIDIA GPU or ComfyUI regression test was executed; Slice 31 does not change the FLUX generation workflow." + ] +} diff --git a/docs/SLICE_32_BATCH_IMAGE_FACTORY.md b/docs/SLICE_32_BATCH_IMAGE_FACTORY.md new file mode 100644 index 0000000..987de08 --- /dev/null +++ b/docs/SLICE_32_BATCH_IMAGE_FACTORY.md @@ -0,0 +1,75 @@ +# Slice 32 – Batch Image Factory + +## Release + +- Vendoo-Version: `1.27.0` +- Basis: `1.26.1` +- Release-Typ: kumulatives Drüberkopier-Paket + +## Ziel + +Die zentrale Bildergalerie wird um eine produktive Batch-Bildverarbeitung ergänzt. Quellbilder bleiben unverändert; jede erzeugte Ausgabe wird als neue Bildversion gespeichert. + +## Funktionsumfang + +- eigener Hauptmenüpunkt **Bildproduktion** +- Auswahl von Bildern aus FLUX, AI, Artikeln, Editorversionen und früheren Batch-Ausgaben +- Übergabe ausgewählter Bilder direkt aus der Galerie +- mehrere Quellbilder und mehrere Produktionsprofile je Auftrag +- acht integrierte Profile: + - eBay Hauptbild + - Vinted Hochformat + - Kleinanzeigen + - Etsy Produktbild + - Instagram Quadrat + - Instagram Hochformat + - Webshop WebP + - Original optimiert +- eigene Produktionsprofile mit Zielbreite/-höhe, Fit-Modus, Hintergrund, Format, Qualität und Dateinamensschema +- optionale Hintergrundentfernung mit Sensitivität +- optionales Wasserzeichen mit Text, Position und Deckkraft +- persistente Aufträge und Einzelpositionen in SQLite +- Pause, Fortsetzen und Abbruch +- Retry aller fehlgeschlagenen Positionen +- Retry einzelner Positionen +- Wiederaufnahme nach Vendoo-Neustart +- 5/10/15 Aufträge pro Seite +- ZIP-Export aller fertigen Ausgaben eines Auftrags +- automatische Aufnahme der Ergebnisse in die Bildergalerie unter Quelle **Produktion** + +## Datenbank + +Neue additive Tabellen: + +- `image_batch_profiles` +- `image_batch_jobs` +- `image_batch_job_items` + +Bestehende Tabellen und Nutzerdaten werden nicht entfernt oder zurückgesetzt. + +## Dateisystem + +Ergebnisse werden unter folgendem Bereich erzeugt: + +```text +uploads/batch// +``` + +Die Originalbilder bleiben erhalten. Auftragsdaten können gelöscht werden, ohne die erzeugten Bilddateien zu löschen. + +## Renderer + +`lib/image-editor.mjs` unterstützt zusätzlich: + +- `resize.fit = inside | contain | cover` +- definierte Hintergrundfarbe bei `contain` +- kontrollierten Ausgabeordner +- kontrollierten Dateinamen +- Duplikatschutz durch automatische Nummerierung + +## Sperrregeln + +- FASHN und Virtual Try-on bleiben entfernt. +- FLUX bleibt ein reiner Prompt-Bildgenerator. +- Keine `.env`, Datenbank, Uploads, API-Schlüssel, Modelle oder `node_modules` werden ausgeliefert. +- Keine Nutzerdaten werden durch Migrationen gelöscht. diff --git a/docs/SLICE_32_VERIFICATION.json b/docs/SLICE_32_VERIFICATION.json new file mode 100644 index 0000000..b6bd6c0 --- /dev/null +++ b/docs/SLICE_32_VERIFICATION.json @@ -0,0 +1,21 @@ +{ + "version": "1.27.0", + "slice": 32, + "name": "Batch Image Factory", + "static_release_gate": true, + "syntax_gate": true, + "html_id_gate": true, + "css_balance_gate": true, + "database_migration_gate": true, + "image_render_gate": true, + "built_in_profiles": 8, + "persistent_jobs": true, + "pause_resume_cancel_retry": true, + "restart_recovery": true, + "gallery_integration": true, + "zip_export": true, + "native_better_sqlite3_worker_test": false, + "native_test_note": "Die Build-Umgebung konnte das better-sqlite3-Binary wegen nicht erreichbarer externer Build-Artefakte nicht neu kompilieren. Tabellenmigrationen wurden mit Python SQLite geprüft; der Sharp-Renderer wurde nativ ausgeführt.", + "real_windows_acceptance": false, + "real_windows_acceptance_note": "Installation, Windows-PowerShell, produktive Laufwerke und Browserbedienung müssen auf dem Zielsystem bestätigt werden." +} diff --git a/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md b/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md new file mode 100644 index 0000000..4713f0a --- /dev/null +++ b/docs/SLICE_33_ARTICLE_QUALITY_CENTER.md @@ -0,0 +1,28 @@ +# Slice 33 – Artikel Quality Center + +Version: 1.28.0 + +## Ziel + +Vendoo prüft Artikel vor dem Publishing nachvollziehbar auf Vollständigkeit, Bildqualität, Organisation, mögliche Duplikate und Plattformtauglichkeit. Ergebnisse werden als Score von 0 bis 100 mit Noten A bis E dargestellt. + +## Funktionsumfang + +- eigener Navigationsbereich `Qualitätscenter` +- harte Blocker getrennt von Warnungen und Empfehlungen +- Prüfung von Titel, Beschreibung, Preis, Kategorie, Zustand, SKU, Lagerort, Tags und Bildern +- plattformbezogene Vendoo-Prüfprofile +- Bilddatei-, Format-, Abmessungs- und Auflösungsprüfung +- Duplikaterkennung über gleiche SKU und Titelähnlichkeit +- Gebühren und geschätzter Erlös im Bericht +- persistenter Score-Verlauf +- Suche, Filter und Pagination +- AI-Vorschläge über den aktiven Text-AI-Provider +- verpflichtender Vorher-/Nachher-Vergleich und Nutzerbestätigung vor dem Speichern + +## Sicherheitsregeln + +- AI darf keine Marken, Größen, Farben, Schäden, Echtheit, technischen Daten oder Lieferumfänge erfinden. +- AI-Vorschläge werden niemals automatisch gespeichert. +- Bestehende Artikel- und Bilddaten werden nicht destruktiv verändert. +- FASHN, VTO und Virtual Try-on bleiben entfernt. diff --git a/docs/SLICE_33_VERIFICATION.json b/docs/SLICE_33_VERIFICATION.json new file mode 100644 index 0000000..6b14ad9 --- /dev/null +++ b/docs/SLICE_33_VERIFICATION.json @@ -0,0 +1,18 @@ +{ + "version": "1.28.0", + "slice": 33, + "name": "Article Quality Center", + "gates": { + "quality_navigation": true, + "score_0_100": true, + "grades_a_to_e": true, + "blockers_warnings_recommendations": true, + "image_metadata_inspection": true, + "duplicate_detection": true, + "persistent_history": true, + "fees_and_net": true, + "ai_proposals_require_confirmation": true, + "responsive_workspace": true, + "vto_removed": true + } +} diff --git a/docs/SLICE_34_PUBLISHING_HARDENING.md b/docs/SLICE_34_PUBLISHING_HARDENING.md new file mode 100644 index 0000000..7917dc6 --- /dev/null +++ b/docs/SLICE_34_PUBLISHING_HARDENING.md @@ -0,0 +1,44 @@ +# Slice 34 – Publishing Hardening + +Version: 1.29.0 + +## Ziel + +Der Publishing-Pfad wird von einzelnen Direktaktionen und einer separaten eBay-Warteschlange auf eine gemeinsame, persistente und nachvollziehbare Publishing-Queue umgestellt. + +## Funktionen + +- plattformübergreifende Queue für eBay, Etsy, Vinted und Kleinanzeigen +- API-Modus für eBay und Etsy +- bestätigungspflichtiger Extension-Modus für Vinted und Kleinanzeigen +- Schutz gegen parallele oder doppelte Veröffentlichungsaufträge +- Schutz gegen erneutes Veröffentlichen bereits als live protokollierter Artikel +- automatische Wiederholungen mit wachsendem Warteabstand +- Wiederaufnahme unterbrochener API-Aufträge nach Vendoo-Neustart +- Abbruch und manueller Retry +- sichere Löschung ausschließlich abgeschlossener lokaler Auftragsprotokolle +- Ereignis- und Fehlerprotokoll +- Speicherung externer IDs, URLs und eBay Offer-IDs +- eBay Statusabgleich, Aktualisierung und Beenden +- Etsy Statusabgleich +- geplante Veröffentlichungen werden an die gemeinsame Queue übergeben +- Vendoo Link 1.8.0 bestätigt Extension-Aufträge über den bestehenden Publish-Statusweg + +## Plattformgrenzen + +Vinted und Kleinanzeigen werden weiterhin nicht über private oder inoffizielle APIs automatisiert. Die Queue stellt den Artikel für die Browser-Extension bereit; der Nutzer bleibt für das Absenden und die Statusbestätigung verantwortlich. + +Etsy erzeugt mit dem bestehenden API-Ablauf zunächst einen Etsy-Entwurf. Vendoo kennzeichnet diesen Status ausdrücklich als „Entwurf erstellt“ und behauptet nicht, dass der Artikel bereits öffentlich live ist. + +## Datenbank + +Neue additive Tabellen: + +- `publishing_jobs` +- `publishing_events` + +Erweiterte Tabelle: + +- `publish_log` mit `offer_id`, `sync_status`, `last_synced_at`, `ended_at` und `updated_at` + +Es werden keine bestehenden Artikel, Bilder, API-Schlüssel, `.env`-Dateien oder Veröffentlichungsdaten gelöscht. diff --git a/docs/SLICE_34_VERIFICATION.json b/docs/SLICE_34_VERIFICATION.json new file mode 100644 index 0000000..30b9a9c --- /dev/null +++ b/docs/SLICE_34_VERIFICATION.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "version": "1.29.0", + "js_files": 69, + "json_files": 25, + "html_ids": 728, + "css_blocks": 3127, + "nav_targets": 16, + "extension_packages": 4, + "persistent_publishing_queue": true, + "duplicate_publish_guard": true, + "automatic_retry": true, + "restart_recovery": true, + "extension_completion": true, + "ebay_sync_update_end": true, + "etsy_sync": true, + "legacy_ebay_queue_migration": true, + "native_runtime_test": false, + "native_runtime_test_reason": "better-sqlite3 konnte in der Build-Umgebung wegen temporär nicht erreichbarer GitHub-/Node-Header nicht kompiliert werden.", + "real_platform_acceptance": { + "windows": false, + "ebay": false, + "etsy": false, + "vinted_extension": false, + "kleinanzeigen_extension": false + } +} diff --git a/docs/UI_FOUNDATION_2026.md b/docs/UI_FOUNDATION_2026.md new file mode 100644 index 0000000..427fb32 --- /dev/null +++ b/docs/UI_FOUNDATION_2026.md @@ -0,0 +1,41 @@ +# Vendoo UI Foundation 2026 + +## Status + +Die Designrichtung ist durch die freigegebenen Referenzen in `MockupDesign/` verbindlich festgelegt. + +## Leitidee + +Vendoo ist eine **Commerce Workbench**, kein generisches Statistik-Dashboard. + +Der zentrale Arbeitsfluss lautet: + +`Erfassen → AI-Prüfung → Bereit → Geplant → Veröffentlicht → Verkauft` + +## Verbindliche Gestaltungsregeln + +- warme Off-White-Grundflächen und Graphit-Typografie +- Vendoo-Orange nur als gezielter Aktions- und Fokusakzent +- Produktbilder bilden die wichtigste visuelle Ebene +- Statusfarben transportieren Bedeutung statt Dekoration +- schmale Hauptnavigation und kontextuelle Detailflächen +- keine austauschbaren KPI-Kartenlandschaften +- keine violett-blauen Standardverläufe +- keine dekorativen Charts ohne operative Funktion +- wenige, bewusst eingesetzte Rundungen und Schatten +- Dark Mode wird eigenständig über CSS Custom Properties gestaltet + +## Slice 01 + +Dieser Slice führt ein: + +- neue App-Shell mit kompakter Navigation und Command Search +- zentrale Design-Tokens für Light und Dark Mode +- neue Startseite `Studio Flow` +- Workflow-Dashboard-API unter `/api/dashboard/workflow` +- Attention Queue, Activity Stream und Artikel-Inspector +- unveränderte Vanilla-JavaScript-/Express-/SQLite-Architektur + +## Noch nicht Teil dieses Slices + +Die vollständige visuelle Umstellung von Listings, Generator, Publish, Lager, Vorlagen, Einstellungen und Admin erfolgt in nachfolgenden isolierten Slices auf derselben Foundation. diff --git a/docs/UI_UX_FIXES_SLICE_03.md b/docs/UI_UX_FIXES_SLICE_03.md new file mode 100644 index 0000000..a987762 --- /dev/null +++ b/docs/UI_UX_FIXES_SLICE_03.md @@ -0,0 +1,26 @@ +# Vendoo UI 2026 – Slice 03 + +## Umfang + +### Print Studio + +Etiketten und Datenblätter werden innerhalb der Vendoo-Oberfläche vorbereitet. Das Print Studio bietet eine A4-Vorschau, Etikettenformate 90 × 50 mm und 62 × 29 mm, mehrere Exemplare sowie eine optionale Preisanzeige. Erst der abschließende Druckbefehl öffnet den normalen Systemdruckdialog. Es wird kein zusätzliches Browserfenster erzeugt. + +### Studio Flow Media Fit + +Produktbilder in Flow-Karten, Inspector und Attention Queue werden vollständig dargestellt. Die Medienflächen verwenden `object-fit: contain`, zentrierte Ausrichtung und neutrale Produktflächen. + +### Vinted Extension + +Die Extension verwendet die Vendoo-Designsprache mit warmen Flächen, Graphittypografie und Vendoo-Orange. Produktbilder werden nicht mehr direkt als HTTP-Bild in die Vinted-Seite eingebettet, sondern durch den Background-Service-Worker geladen und als Data-URL eingesetzt. Das vermeidet Blockaden durch Seiten-CSP oder gemischte Inhalte. + +## Geänderte Dateien + +- `public/app.js` +- `public/style.css` +- `extension/background.js` +- `extension/content.js` +- `extension/content.css` +- `extension/popup.html` +- `extension/popup.js` +- `extension/manifest.json` diff --git a/docs/architecture/DESIGN_SYSTEM_1_35_0.md b/docs/architecture/DESIGN_SYSTEM_1_35_0.md new file mode 100644 index 0000000..ebb4b33 --- /dev/null +++ b/docs/architecture/DESIGN_SYSTEM_1_35_0.md @@ -0,0 +1,39 @@ +# Vendoo 1.35.0 – Design-System- und Theme-Vertrag + +## Quelle der Wahrheit + +Die editierbare Tokenquelle liegt unter: + +`public/design-system/tokens/source.json` + +Aus ihr erzeugt `tools/generate-design-tokens.mjs` deterministisch: + +- `public/design-system/tokens.css` +- `public/design-system/theme-contract.json` + +Direkte Änderungen an `tokens.css` sind nicht zulässig. + +## Ebenen + +- Primitive/semantische Farb-, Radius-, Schatten-, Dichte-, Layout- und Bewegungswerte +- CSS-Variablen mit Präfix `--vd-*` +- Kompatibilitätsbrücke für bestehende Variablen wie `--bg`, `--accent` und `--radius` + +## Themes + +1. `light` +2. `dark` +3. `contrast` +4. Browserpräferenz `system` über die Theme Runtime + +## Sicherheit + +Der zukünftige Theme Manager darf ausschließlich Tokens mit `customizable: true` verändern. Freies CSS, HTML, URLs, JavaScript, Schattenausdrücke und beliebige Variablennamen sind nicht erlaubt. Farben und Dimensionen werden typ- und bereichsgeprüft. Ein Theme wird nie ungeprüft als Stylesheet gespeichert. + +## Nächster Ausbau + +- persistente Themes pro Benutzer und systemweit +- Live-Vorschau in isoliertem Vorschaucontainer +- automatische Kontrastprüfung +- Import/Export eines versionierten Theme-JSON +- Versionsverlauf und Zurücksetzen diff --git a/docs/architecture/MIGRATION_ROADMAP_1_35_0.md b/docs/architecture/MIGRATION_ROADMAP_1_35_0.md new file mode 100644 index 0000000..60afce2 --- /dev/null +++ b/docs/architecture/MIGRATION_ROADMAP_1_35_0.md @@ -0,0 +1,25 @@ +# Vendoo Modularisierungs-Roadmap ab 1.35.0 + +## 1.35.0 – Foundation + +Kernel, Manifeste, Lifecycle, Event Bus, Feature Flags, Policy-/Routenverträge, Design Tokens und Architekturtests. + +## 1.36.0 – Theme Manager + +Persistente sichere Themeverwaltung, Vorschau, Import/Export, Kontrast- und Accessibility-Gates. + +## 1.37.0 – Security & Secrets + +Secret Provider, Schema Registry, strukturierte Logs, sichere Fehlergrenzen und CSP-Migrationsgrundlage. + +## 1.38.0 – Auth, Users & Settings + +Erster vollständig migrierter Backend-/Frontend-Modulverbund einschließlich Vertragstests. + +## 1.39.0 – Listings, Inventory & Media + +Fachmodule, Repository-Schicht, UI-Komponenten und Datenzugriffsgrenzen. + +## 1.40.0 – Publishing, AI & FLUX + +Adapterverträge, Job-Schnittstellen, FLUX-Modul und Vorbereitung des isolierten Extension-Hosts. diff --git a/docs/architecture/MODULE_SYSTEM_1_35_0.md b/docs/architecture/MODULE_SYSTEM_1_35_0.md new file mode 100644 index 0000000..47ada7c --- /dev/null +++ b/docs/architecture/MODULE_SYSTEM_1_35_0.md @@ -0,0 +1,31 @@ +# Vendoo 1.35.0 – Modulsystem + +## Ziel + +Vendoo wird schrittweise als modularer Monolith organisiert. Der bestehende Code bleibt in 1.35.0 funktional unverändert und wird über `legacy-bridge`-Manifeste erfasst. Neue Funktionen müssen die Kernelverträge verwenden; bestehende Bereiche werden in späteren Releases einzeln migriert. + +## Verbindliche Regeln + +1. Jedes Modul besitzt eine eindeutige ID `vendoo.` und ein validiertes `module.json`. +2. Abhängigkeiten müssen explizit und frei von Zyklen sein. +3. Neue Routen benötigen Besitzer, Policy, Auditklasse, Rate-Limit-Klasse und Stabilitätsstatus. +4. Unbekannte Policies verweigern den Zugriff. Es gibt keinen Allow-by-default-Fallback. +5. Module kommunizieren künftig über öffentliche Services, Capabilities und Events, nicht über interne Querimporte. +6. Ein Modul darf langfristig nur seine eigenen Tabellen beziehungsweise Repository-Schnittstellen verändern. +7. Externe Erweiterungen werden nicht im Vendoo-Hauptprozess ausgeführt. Ein isolierter Extension-Host bleibt bis zur sicheren Umsetzung deaktiviert. +8. Modulaktivierung durch bloßes Kopieren einer Datei ist unzulässig. + +## Status 1.35.0 + +Die Bereiche System, Security, Auth, Benutzer, Themes, Medien, Artikel, AI, FLUX Studio, Quality Center, Publishing und Operations sind registriert. Ihr Status `legacy-bridge` bedeutet: Der Vertrag existiert, die bestehende Implementierung liegt aber noch überwiegend in `server.mjs`, `lib/`, `public/app.js` und `public/style.css`. + +## Migrationsmethode + +Vendoo verwendet das Strangler-Verfahren: + +- neue Architektur neben dem Bestand aufbauen, +- einen fachlichen Bereich vollständig migrieren, +- Verhalten und Datenbestand prüfen, +- erst danach den alten Pfad entfernen. + +Kein Big-Bang-Refactoring, keine Framework-Migration und kein Datenbankreset. diff --git a/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md b/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md new file mode 100644 index 0000000..e17fc6f --- /dev/null +++ b/docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md @@ -0,0 +1,29 @@ +# Vendoo 1.35.0 – Sicherheitsarchitektur + +## Neue Grundlage + +- Deny-by-default Policy Engine für neue Kernel-Routen +- explizite Routenverträge +- standardisierte interne Fehler mit Request-ID +- validierte Modulmanifeste und Abhängigkeiten +- Owner-basierter Event-Bus mit sauberem Listener-Abbau +- Feature Flags mit fester Registrierung und sicherem Environment-Override +- sichere Theme-Werte statt frei eingebbarem CSS +- geordneter Kernelstart und Kernelshutdown + +## Bestehender Schutz bleibt aktiv + +Sitzungen, CSRF, Rollen, Berechtigungen, Rate Limits, Origin-/Hostprüfung, Uploadschutz, Audit und Ressourcenlocks werden nicht ersetzt oder umgangen. + +## Offene Produktiv-Gates + +Diese Punkte werden bewusst nicht als bereits gelöst ausgegeben: + +- vollständige CSP ohne `unsafe-inline` +- zentral validierte Request-/Response-Schemas für alle Bestandsrouten +- verschlüsselter Secret Provider je Plattform +- isolierter Extension-Host +- vollständige Repository-Trennung aller Datenbankzugriffe +- automatisierte Browser-, Accessibility- und Penetrationstests + +Bis zu ihrer Umsetzung bleiben die entsprechenden Feature Flags deaktiviert oder die Altpfade unter bestehenden Schutzmechanismen aktiv. diff --git a/docs/architecture/THEME_MANAGER_1_36_0.md b/docs/architecture/THEME_MANAGER_1_36_0.md new file mode 100644 index 0000000..428e219 --- /dev/null +++ b/docs/architecture/THEME_MANAGER_1_36_0.md @@ -0,0 +1,41 @@ +# Vendoo Theme Manager 1.36.0 + +## Ziel + +Der Theme Manager verwaltet Darstellung ausschließlich über einen versionierten Design-Token-Vertrag. Freies CSS, JavaScript, URLs, Dateipfade und nicht freigegebene Tokens werden nicht akzeptiert. + +## Modulgrenze + +`vendoo.themes` ist ab 1.36.0 ein natives Modul. Es besitzt: + +- `theme_profiles` +- `user_theme_preferences` +- Systemstandard in der bestehenden Settings-Ablage +- Tokenvertrag und Accessibility-Prüfung +- Theme-APIs mit Deny-by-default-Policies + +Andere Module greifen nicht direkt auf diese Tabellen zu. + +## Berechtigungen + +- Jeder angemeldete Benutzer darf seine eigene Darstellung speichern. +- Eigene Theme-Profile, Import, Export, Löschen und Systemstandard benötigen `settings.manage`. +- Alle Änderungen an Profilen und Systemstandard werden auditiert. + +## Sichere Werte + +Anpassbar sind nur freigegebene Farb- und Pixel-Dimensionstokens. Farben werden auf Hex/RGB-Kanäle geprüft. Dimensionen besitzen Mindest- und Höchstwerte. Nicht anpassbare Schatten-, Bewegungs- und Layoutverträge können nicht über die Oberfläche überschrieben werden. + +## Accessibility + +Die Validierung prüft zentrale Text-, Flächen-, Marken-, Gefahren- und Linienkontraste. Zusätzlich unterstützt die Runtime: + +- High Contrast +- System-Farbschema +- reduzierte Bewegung +- kompakte, ausgewogene und großzügige UI-Dichte +- tokenisierte Fokus-Ringe und Mindest-Zielgrößen + +## Migration + +Bestehende `vendoo-theme`-LocalStorage-Werte werden von der Runtime als Fallback übernommen. Nach Anmeldung lädt die Oberfläche die serverseitige Benutzerpräferenz. Datenbanktabellen werden additiv angelegt; vorhandene Benutzer und Einstellungen bleiben erhalten. diff --git a/docs/superpowers/specs/2026-07-06-autolister-features-design.md b/docs/superpowers/specs/2026-07-06-autolister-features-design.md new file mode 100644 index 0000000..5e7f7f7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-autolister-features-design.md @@ -0,0 +1,66 @@ +# AutoLister Feature-Erweiterungen + +## 1. Kategorie-Fixes + +### a) Auto-Matching nach AI-Generierung +- `showMeta()` ruft `/api/categories/:platform/match?q=` auf +- Bestes Ergebnis wird im Picker vorausgewählt +- Freitext-Fallback wenn kein Match (Score < 50) + +### b) Plattform-Wechsel aktualisiert Picker +- Plattform-Button-Click prüft ob Meta-Section sichtbar ist +- Wenn ja: Kategorie-Picker wird mit neuer Plattform neu initialisiert +- Bisheriger Wert wird als Suchquery gegen neue Plattform gematcht + +### c) Extension nutzt Server-API +- `content.js` lädt Kategorien via `/api/categories/vinted` statt lokaler Datei +- `vinted-categories.js` wird aus manifest.json entfernt (Datei bleibt als Fallback) +- Match-Funktion nutzt `/api/categories/vinted/match?q=` + +## 2. WYSIWYG ↔ HTML Sync + +- Quill `text-change` Event → HTML-Source-Textarea wird aktualisiert (`editor.root.innerHTML`) +- HTML-Source `input` Event → Quill übernimmt via `editor.root.innerHTML = textarea.value` +- Toolbar-Icons bekommen `title`-Attribute: Bold→"Fett", Italic→"Kursiv", Underline→"Unterstrichen", Strike→"Durchgestrichen", List Ordered→"Nummerierte Liste", List Bullet→"Aufzählung", Blockquote→"Zitat", Link→"Link einfügen", Clean→"Formatierung entfernen", Color→"Textfarbe", Background→"Hintergrundfarbe", Align→"Ausrichtung", Header→"Überschrift" + +## 3. Veröffentlichungsstatus in Historie + +- `loadHistory()` ruft zusätzlich `/api/publish/status?ids=` auf +- Pro Listing in der Liste: farbige Badges für jede Plattform + - Grün: veröffentlicht + - Orange: kopiert + - Kein Badge: nicht veröffentlicht +- Badges erscheinen neben dem Status-Badge in der History-Liste + +## 4. SKU/Artikelnummer-System + +### DB +- Neues Feld: `sku TEXT` in listings-Tabelle +- Auto-generiert: `AL-XXXX` (fortlaufend, 4-stellig zero-padded) +- Jederzeit editierbar auf eigene Bezeichnung + +### UI-Integration +- Generator Meta-Section: SKU-Feld (readonly, auto-generiert beim Erstellen) +- Historie-Liste: SKU als erste Info nach Thumbnail +- Detail-View: editierbares SKU-Feld +- Publish-Liste: SKU angezeigt + +### QR-Code +- Client-seitig via qrcode.js (CDN) +- QR-Inhalt: `{"sku":"AL-0001","id":42,"url":"http://localhost:8124/#listing/42"}` +- Angezeigt im Detail-View + +### Druck-Formate +**Kompakt-Etikett (~90×50mm):** +- QR links, rechts: SKU, Titel (max 40 Zeichen), Preis, Plattform +- 4 Etiketten pro A4-Seite bei Batch-Druck + +**Produkt-Datenblatt (A4):** +- Header: QR + SKU groß +- Foto-Thumbnails (max 4) +- Titel, Beschreibung (max 200 Zeichen), Marke, Kategorie, Größe, Farbe, Zustand, Preis +- Plattform, Erstelldatum + +### Druck-Buttons +- Detail-View: "Etikett drucken" + "Datenblatt drucken" +- Historie Batch-Bar: "Etiketten drucken" (mehrere auf einem Blatt) diff --git a/extension/README.txt b/extension/README.txt new file mode 100644 index 0000000..1cc6bc2 --- /dev/null +++ b/extension/README.txt @@ -0,0 +1,14 @@ +VERALTETER ORDNER + +Die Browser-Erweiterungen befinden sich ab Version 1.3.0 unter: +- extensions/chrome +- extensions/edge +- extensions/firefox + +Bitte die bisher geladene Erweiterung entfernen und den passenden neuen Unterordner laden. + + +HOTFIX 1.9.0: +Nach dem Drueberkopieren die Erweiterung unter chrome://extensions bzw. edge://extensions mit "Neu laden" aktualisieren und Vinted neu laden. Die Zustandsauswahl darf danach das Titelfeld nicht mehr ueberschreiben. + +VERSION 1.9.0: Publishing-Queue-Kopplung und Abschlussbestätigung. diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extension/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extension/content.css b/extension/content.css new file mode 100644 index 0000000..305e62a --- /dev/null +++ b/extension/content.css @@ -0,0 +1,455 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..4604f7a --- /dev/null +++ b/extension/content.js @@ -0,0 +1,1453 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 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 = ` +
                                              + + + ... + + + + +
                                              +
                                              + + + +
                                              +
                                              + + + 0 ausgewählt +
                                              +
                                              +
                                              +
                                              +
                                              Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                              +
                                              0offen
                                              +
                                              +
                                                +
                                                +
                                                +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  Batch-Modus
                                                  + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                  +
                                                  +
                                                  Pause zwischen Artikeln (Sek.)
                                                  + +
                                                  +
                                                  +
                                                  Server URL
                                                  + +
                                                  +
                                                  + +
                                                  +
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  + +
                                                  + `; + 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 = ``; 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 = '
                                                • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                • +
                                                  + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                  +
                                                  ${statusHtml}Vinted
                                                  +
                                                  ${esc(l.title || 'Ohne Titel')}
                                                  +
                                                  ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                  +
                                                  ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                  +
                                                  +
                                                  ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                  +
                                                  + + +
                                                  +
                                                • `; + }).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) await fillListingData(listing, { includePhotos: false }); + })); + 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 = '
                                                • 📦Noch keine Artikel veröffentlicht.
                                                • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                  +
                                                  ${esc(l.title || 'Ohne Titel')}
                                                  +
                                                  ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                  +
                                                  + Erledigt +
                                                • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + `; + 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-detail').addEventListener('click', () => showDetail(id)); + actions.querySelector('#al-selection-more').addEventListener('click', () => actions.querySelector('#al-selection-menu').classList.toggle('hidden')); + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                  + + +
                                                  + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                  + Bereit +

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

                                                  + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                  Kategorie
                                                  ${esc(listing.category || 'Nicht gesetzt')}
                                                  Marke
                                                  ${esc(listing.brand || '–')}
                                                  Größe
                                                  ${esc(listing.size || '–')}
                                                  Zustand
                                                  ${esc(listing.condition || '–')}
                                                  +
                                                  +
                                                  +
                                                  ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                  + +
                                                  +

                                                  Veröffentlichen auf Vinted

                                                  +
                                                  +
                                                  1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                  2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                  3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                  4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                  5. 5VeröffentlichungListing abschließen
                                                  +
                                                  Auto-Fill · Vinted

                                                  Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                  • ✓ Titel
                                                  • ✓ Preis
                                                  • ✓ Kategorie
                                                  • ✓ Größe & Marke
                                                  • ✓ Beschreibung
                                                  • ✓ Bilder
                                                  ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                  +
                                                  +
                                                  + +
                                                  Listing-Daten prüfen & bearbeiten +
                                                  +
                                                  Titel
                                                  + +
                                                  +
                                                  +
                                                  Beschreibung
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  Preis
                                                  + +
                                                  +
                                                  +
                                                  Zustand
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  Marke
                                                  + +
                                                  +
                                                  +
                                                  Größe
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  Kategorie (Vinted)
                                                  +
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  Farbe
                                                  + +
                                                  +
                                                  +
                                                  AI-Kategorie (Original)
                                                  + +
                                                  +
                                                  +
                                                  +
                                                  Tags
                                                  + +
                                                  + +
                                                  + + +
                                                  + + +

                                                  Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                  +
                                                  + +
                                                  + Entwurf speichern oder veröffentlichen +
                                                  + + + + +
                                                  +
                                                  + +
                                                  + `; + + 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 = '
                                                  Keine Kategorie gefunden
                                                  '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                  +
                                                  ${esc(m.name)}
                                                  +
                                                  ${esc(m.path.slice(0, -1).join(' > '))}
                                                  +
                                                  + `).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 = '
                                                  '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                  `; + html += `
                                                  ${esc(main.name)}
                                                  `; + html += `
                                                  `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                  `; + html += `
                                                  ${esc(sub.name)}
                                                  `; + html += `
                                                  `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                  ${esc(leaf.name)}
                                                  `; + } + } + html += '
                                                  '; + } + } + html += '
                                                  '; + } + html += '
                                                  '; + 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 = ` +
                                                  +
                                                  +
                                                  ${esc(cat.name)}
                                                  +
                                                  ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                  +
                                                  + +
                                                  + `; + 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 = ` +
                                                  +
                                                  + Modus: ${modeText} +
                                                  +
                                                  +
                                                  +
                                                  + 0 / ${STATE.batchProgress.total} + 0% +
                                                  +
                                                  + +
                                                  +
                                                  + `; + 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 += `
                                                  • ${esc(title.substring(0, 30))}... ${step}
                                                  `; + 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 += `
                                                  ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                  `; + 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(); + const pending = await storageGet(['pendingFillListingId']); + if (pending.pendingFillListingId) { + const listing = STATE.queue.find(item => item.id === Number(pending.pendingFillListingId)); + if (listing) { + await storageSet({ pendingFillListingId: null }); + await fillListingData(listing, { includePhotos: false }); + showDetail(listing.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; + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); + diff --git a/extension/icon-128.png b/extension/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extension/icon-128.png differ diff --git a/extension/icon-16.png b/extension/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extension/icon-16.png differ diff --git a/extension/icon-32.png b/extension/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extension/icon-32.png differ diff --git a/extension/icon-48.png b/extension/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extension/icon-48.png differ diff --git a/extension/icon.svg b/extension/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extension/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..909cf8c --- /dev/null +++ b/extension/popup.css @@ -0,0 +1 @@ +:root{--bg:#f4f0e9;--surface:#fffdf9;--surface-2:#f8f5ef;--line:#ddd6cb;--ink:#22201d;--muted:#7b746b;--brand:#e63b17;--brand-dark:#c92f0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#bd4339}*{box-sizing:border-box}body{width:420px;min-height:610px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.popup-shell{min-height:610px;display:grid;grid-template-rows:auto 1fr auto;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.055),transparent 38%),var(--bg)}.popup-header{height:68px;display:flex;align-items:center;gap:10px;padding:0 17px;border-bottom:1px solid var(--line);background:rgba(255,253,249,.97)}.brand-lockup{display:flex;align-items:center;gap:10px;min-width:0}.brand-lockup img{width:36px;height:36px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:15px;letter-spacing:-.035em}.brand-lockup span{font-size:9px;color:var(--muted)}.connection-chip{margin-left:auto;min-height:25px;display:flex;align-items:center;gap:6px;padding:0 9px;border-radius:999px;background:#eee9e1;color:var(--muted);font-size:9px;font-weight:800}.connection-chip i{width:7px;height:7px;border-radius:50%;background:#aaa}.connection-chip.ok{background:var(--success-soft);color:var(--success)}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:rgba(189,67,57,.1);color:var(--danger)}.connection-chip.error i{background:var(--danger)}.icon-button{width:32px;height:32px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--muted);font-size:15px;cursor:pointer}.icon-button:hover{border-color:var(--line);background:var(--surface-2);color:var(--ink)}.popup-content,.settings-view{padding:15px 17px 18px;overflow:auto}.queue-card,.listing-card,.empty-card{border:1px solid var(--line);border-radius:15px;background:var(--surface);box-shadow:0 10px 28px rgba(42,36,29,.055)}.queue-card{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:11px;align-items:center;padding:14px}.queue-icon{width:36px;height:36px;display:grid;place-items:center;border-radius:11px;background:var(--brand-soft);color:var(--brand);font-size:17px;font-weight:900}.queue-card span{display:block;color:var(--brand);font-size:8px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}.queue-card strong{display:block;margin-top:3px;font-size:12px}.queue-card small{display:block;margin-top:2px;color:var(--muted);font-size:8px}.queue-progress{height:5px;margin-top:9px;overflow:hidden;border-radius:999px;background:#e9e3da}.queue-progress i{display:block;width:0;height:100%;border-radius:inherit;background:var(--brand);transition:width .25s ease}.queue-count{display:grid;justify-items:end;gap:7px}.queue-count b{font-size:11px}.queue-count button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.listing-card{margin-top:12px;padding:12px}.listing-card>header{display:flex;align-items:center;justify-content:space-between;padding:2px 2px 11px}.listing-card>header>div{display:flex;align-items:center;gap:7px}.listing-card>header span{font-size:11px;font-weight:850}.listing-card>header b{padding:3px 7px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:8px}.listing-card>header button{border:0;background:transparent;color:var(--muted);font-size:14px}.listing-overview{display:grid;grid-template-columns:128px minmax(0,1fr);gap:12px}.listing-photo{height:166px;display:grid;place-items:center;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#fff,#eee9e1)}.listing-photo img{width:100%;height:100%;object-fit:contain;padding:7px}.listing-copy{min-width:0}.listing-copy h2{margin:2px 0 5px;font-size:13px;line-height:1.32;letter-spacing:-.025em}.listing-copy p{margin:0;color:var(--muted);font-size:9px}.listing-copy code{display:block;margin:7px 0 11px;color:var(--muted);font-size:8px}.listing-copy dl{display:grid;grid-template-columns:62px minmax(0,1fr);gap:7px 5px;margin:0;font-size:9px}.listing-copy dt{color:var(--muted)}.listing-copy dd{margin:0;font-weight:700}.platform-chip{display:inline-flex;padding:2px 6px;border-radius:6px;background:#eee9e1;font-size:8px}.safe-notice{display:grid;gap:2px;margin:12px 0 3px;padding:10px 11px;border:1px solid rgba(33,131,86,.18);border-radius:10px;background:var(--success-soft)}.safe-notice b{color:var(--success);font-size:9px}.safe-notice span{color:var(--muted);font-size:8px;line-height:1.4}.primary-action,.secondary-action{min-height:42px;border-radius:10px;font:inherit;font-size:10px;font-weight:850;cursor:pointer}.primary-action{width:100%;display:flex;justify-content:center;align-items:center;gap:8px;border:0;background:var(--brand);color:#fff}.primary-action:hover{background:var(--brand-dark)}.primary-action span{margin-left:auto;padding-right:5px}.action-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.secondary-action{width:100%;border:1px solid var(--line);background:var(--surface);color:var(--ink)}.secondary-action:hover{border-color:var(--brand);color:var(--brand)}.empty-card{padding:38px 22px;text-align:center}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:10px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:10px;font-weight:850}.settings-view{display:grid;align-content:start;gap:12px}.settings-heading{display:flex;align-items:center;gap:9px;margin-bottom:3px}.settings-heading button{width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:9px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:9px;font-weight:800}.setting input,.setting select{width:100%;height:42px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}footer{min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 17px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:8px}footer button{border:0;background:transparent;color:var(--brand);font-size:9px;font-weight:800;cursor:pointer}.hidden{display:none!important} \ No newline at end of file diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..8b90ac1 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,49 @@ + + + + + + Vendoo Link + + + +
                                                  + + + + + + +
                                                  Vendoo Link v1.9.0
                                                  +
                                                  + + + \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..8fbde6b --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,129 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], current: 0, serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatDate(value) { + const date = new Date(String(value || '').replace(' ', 'T') + 'Z'); + return Number.isNaN(date.getTime()) ? '–' : date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +async function renderCurrent() { + const listing = state.queue[state.current]; + $('#listing-card').classList.toggle('hidden', !listing); + $('#empty-card').classList.toggle('hidden', !!listing); + if (!listing) return; + $('#listing-title').textContent = listing.title || 'Ohne Titel'; + $('#listing-meta').textContent = [listing.size, listing.color, listing.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften'; + $('#listing-sku').textContent = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + $('#listing-price').textContent = listing.price ? `${Number(listing.price).toFixed(2).replace('.', ',')} €` : '–'; + $('#listing-created').textContent = formatDate(listing.created_at); + const src = await imageData(listing.photos?.[0]); + const image = $('#listing-image'); + if (src) { image.src = src; image.classList.remove('hidden'); } else { image.removeAttribute('src'); image.classList.add('hidden'); } +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + state.current = Math.min(state.current, Math.max(0, state.queue.length - 1)); + setConnection(true, 'Verbunden'); + $('#queue-state').textContent = state.queue.length ? 'Bereit zur Übertragung' : 'Queue leer'; + $('#queue-count').textContent = `${state.queue.length} / ${data.total || state.queue.length}`; + $('#queue-progress-fill').style.width = `${data.total ? ((data.total - state.queue.length) / data.total) * 100 : 0}%`; + await renderCurrent(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = error.message; + $('#listing-card').classList.add('hidden'); + $('#empty-card').classList.remove('hidden'); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +function openUrl(url) { openTab(url); } +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#next-listing').addEventListener('click', async () => { if (state.queue.length) state.current = (state.current + 1) % state.queue.length; await renderCurrent(); }); +$('#fill-listing').addEventListener('click', async () => { + const listing = state.queue[state.current]; + if (!listing) return; + await storageSet({ pendingFillListingId: listing.id }); + openUrl('https://www.vinted.de/items/new'); + window.close(); +}); + +$('#prepare-images').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-queue').addEventListener('click', () => openUrl(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openUrl(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openUrl(state.serverUrl)); + +init(); diff --git a/extension/vinted-categories.js b/extension/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extension/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000..e49e2a1 --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,38 @@ +# Vendoo Browser Extensions + +- `chrome/` – Chrome, Manifest V3 +- `edge/` – Microsoft Edge, Manifest V3 +- `firefox/` – Firefox, WebExtension-Paket + +## Lokal laden + +### Chrome +`chrome://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/chrome` auswählen. + +### Edge +`edge://extensions` öffnen → Entwicklermodus → **Entpackte Erweiterung laden** → Ordner `extensions/edge` auswählen. + +### Firefox +`about:debugging#/runtime/this-firefox` öffnen → **Temporäres Add-on laden** → `extensions/firefox/manifest.json` auswählen. + +Die Serveradresse kann im Popup geändert werden. Für öffentliche Server muss die Vendoo-Adresse per HTTPS erreichbar sein. + +## Serverzugriff + +Die Erweiterung kann mit `localhost`, einer LAN-Adresse oder einer öffentlichen HTTPS-Adresse verbunden werden. Da die Serveradresse frei konfigurierbar ist, verlangen die Browser beim Laden Zugriff auf HTTP-/HTTPS-Seiten. Die Erweiterung verwendet diesen Zugriff ausschließlich für die eingetragene Vendoo-Instanz und die unterstützten Vinted-Seiten. + +## Alte Erweiterung ersetzen + +Der frühere Ordner `extension/` wird nicht mehr verwendet. Eine bereits geladene Altversion zuerst entfernen und danach den passenden Ordner unter `extensions/` laden. + +## Hotfix 1.9.0 + +Die Feldsuche der Vinted-Erweiterung verwendet keine unspezifischen Elterncontainer mehr. Nach einem Vendoo-Update muss die entpackt geladene Erweiterung in `chrome://extensions` bzw. `edge://extensions` über **Neu laden** aktualisiert und die geöffnete Vinted-Seite einmal neu geladen werden. + + +## Version 1.9.0 / Safari + +Vendoo enthält jetzt eine zentrale Installationsseite und eine Safari-WebExtension-Quelle. Safari benötigt macOS und Xcode zur Verpackung und Signierung. + +## Version 1.9.0 – Publishing Hardening +Die Extension arbeitet mit der gehärteten Publishing-Queue zusammen. Bestätigte Veröffentlichungen schließen zugehörige Extension-Aufträge automatisch ab. diff --git a/extensions/background.js b/extensions/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extensions/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extensions/chrome/background.js b/extensions/chrome/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extensions/chrome/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extensions/chrome/content.css b/extensions/chrome/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/extensions/chrome/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extensions/chrome/content.js b/extensions/chrome/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/extensions/chrome/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                                                  + + + ... + + + + +
                                                  +
                                                  + + + +
                                                  +
                                                  + + + 0 ausgewählt +
                                                  +
                                                  +
                                                  +
                                                  +
                                                  Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                                  +
                                                  0offen
                                                  +
                                                  +
                                                    +
                                                    +
                                                    +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      Batch-Modus
                                                      + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                      +
                                                      +
                                                      Pause zwischen Artikeln (Sek.)
                                                      + +
                                                      +
                                                      +
                                                      Server URL
                                                      + +
                                                      +
                                                      + +
                                                      +
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      + +
                                                      + `; + 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 = ``; 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 = '
                                                    • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                    • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                    • +
                                                      + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                      +
                                                      ${statusHtml}Vinted
                                                      +
                                                      ${esc(l.title || 'Ohne Titel')}
                                                      +
                                                      ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                      +
                                                      ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                      +
                                                      +
                                                      ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                      +
                                                      + + + +
                                                      +
                                                    • `; + }).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 = '
                                                    • 📦Noch keine Artikel veröffentlicht.
                                                    • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                    • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                      +
                                                      ${esc(l.title || 'Ohne Titel')}
                                                      +
                                                      ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                      +
                                                      + Erledigt +
                                                    • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                      + + +
                                                      + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                      + Bereit +

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

                                                      + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                      Kategorie
                                                      ${esc(listing.category || 'Nicht gesetzt')}
                                                      Marke
                                                      ${esc(listing.brand || '–')}
                                                      Größe
                                                      ${esc(listing.size || '–')}
                                                      Zustand
                                                      ${esc(listing.condition || '–')}
                                                      +
                                                      +
                                                      +
                                                      ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                      + +
                                                      +

                                                      Veröffentlichen auf Vinted

                                                      +
                                                      +
                                                      1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                      2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                      3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                      4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                      5. 5VeröffentlichungListing abschließen
                                                      +
                                                      Auto-Fill · Vinted

                                                      Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                      • ✓ Titel
                                                      • ✓ Preis
                                                      • ✓ Kategorie
                                                      • ✓ Größe & Marke
                                                      • ✓ Beschreibung
                                                      • ✓ Bilder
                                                      ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                      +
                                                      +
                                                      + +
                                                      Listing-Daten prüfen & bearbeiten +
                                                      +
                                                      Titel
                                                      + +
                                                      +
                                                      +
                                                      Beschreibung
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      Preis
                                                      + +
                                                      +
                                                      +
                                                      Zustand
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      Marke
                                                      + +
                                                      +
                                                      +
                                                      Größe
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      Kategorie (Vinted)
                                                      +
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      Farbe
                                                      + +
                                                      +
                                                      +
                                                      AI-Kategorie (Original)
                                                      + +
                                                      +
                                                      +
                                                      +
                                                      Tags
                                                      + +
                                                      + +
                                                      + + +
                                                      + + +

                                                      Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                      +
                                                      + +
                                                      + Entwurf speichern oder veröffentlichen +
                                                      + + + + +
                                                      +
                                                      + +
                                                      + `; + + 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 = '
                                                      Keine Kategorie gefunden
                                                      '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                      +
                                                      ${esc(m.name)}
                                                      +
                                                      ${esc(m.path.slice(0, -1).join(' > '))}
                                                      +
                                                      + `).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 = '
                                                      '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                      `; + html += `
                                                      ${esc(main.name)}
                                                      `; + html += `
                                                      `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                      `; + html += `
                                                      ${esc(sub.name)}
                                                      `; + html += `
                                                      `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                      ${esc(leaf.name)}
                                                      `; + } + } + html += '
                                                      '; + } + } + html += '
                                                      '; + } + html += '
                                                      '; + 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 = ` +
                                                      +
                                                      +
                                                      ${esc(cat.name)}
                                                      +
                                                      ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                      +
                                                      + +
                                                      + `; + 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 = ` +
                                                      +
                                                      + Modus: ${modeText} +
                                                      +
                                                      +
                                                      +
                                                      + 0 / ${STATE.batchProgress.total} + 0% +
                                                      +
                                                      + +
                                                      +
                                                      + `; + 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 += `
                                                      • ${esc(title.substring(0, 30))}... ${step}
                                                      `; + 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 += `
                                                      ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                      `; + 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(); +})(); + diff --git a/extensions/chrome/icon-128.png b/extensions/chrome/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extensions/chrome/icon-128.png differ diff --git a/extensions/chrome/icon-16.png b/extensions/chrome/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extensions/chrome/icon-16.png differ diff --git a/extensions/chrome/icon-32.png b/extensions/chrome/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extensions/chrome/icon-32.png differ diff --git a/extensions/chrome/icon-48.png b/extensions/chrome/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extensions/chrome/icon-48.png differ diff --git a/extensions/chrome/icon.svg b/extensions/chrome/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extensions/chrome/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/chrome/manifest.json b/extensions/chrome/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/extensions/chrome/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/extensions/chrome/popup.css b/extensions/chrome/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/extensions/chrome/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/extensions/chrome/popup.html b/extensions/chrome/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/extensions/chrome/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                                      + + + + + + +
                                                      + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                                                      +
                                                      + + + diff --git a/extensions/chrome/popup.js b/extensions/chrome/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/extensions/chrome/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                                      +
                                                      ${escapeHtml(item.title || 'Produktbild')}
                                                      +
                                                      +
                                                      +
                                                      ${escapeHtml(item.title || 'Ohne Titel')}
                                                      +
                                                      ${formatPrice(item.price)}
                                                      +
                                                      +
                                                      ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                                      +
                                                      ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                                      +
                                                      + + + + +
                                                      +
                                                      +
                                                      + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/extensions/chrome/vinted-categories.js b/extensions/chrome/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extensions/chrome/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/extensions/content.css b/extensions/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/extensions/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extensions/content.js b/extensions/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/extensions/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                                                      + + + ... + + + + +
                                                      +
                                                      + + + +
                                                      +
                                                      + + + 0 ausgewählt +
                                                      +
                                                      +
                                                      +
                                                      +
                                                      Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                                      +
                                                      0offen
                                                      +
                                                      +
                                                        +
                                                        +
                                                        +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          Batch-Modus
                                                          + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                          +
                                                          +
                                                          Pause zwischen Artikeln (Sek.)
                                                          + +
                                                          +
                                                          +
                                                          Server URL
                                                          + +
                                                          +
                                                          + +
                                                          +
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          + +
                                                          + `; + 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 = ``; 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 = '
                                                        • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                        • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                        • +
                                                          + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                          +
                                                          ${statusHtml}Vinted
                                                          +
                                                          ${esc(l.title || 'Ohne Titel')}
                                                          +
                                                          ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                          +
                                                          ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                          +
                                                          +
                                                          ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                          +
                                                          + + + +
                                                          +
                                                        • `; + }).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 = '
                                                        • 📦Noch keine Artikel veröffentlicht.
                                                        • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                        • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                          +
                                                          ${esc(l.title || 'Ohne Titel')}
                                                          +
                                                          ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                          +
                                                          + Erledigt +
                                                        • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                          + + +
                                                          + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                          + Bereit +

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

                                                          + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                          Kategorie
                                                          ${esc(listing.category || 'Nicht gesetzt')}
                                                          Marke
                                                          ${esc(listing.brand || '–')}
                                                          Größe
                                                          ${esc(listing.size || '–')}
                                                          Zustand
                                                          ${esc(listing.condition || '–')}
                                                          +
                                                          +
                                                          +
                                                          ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                          + +
                                                          +

                                                          Veröffentlichen auf Vinted

                                                          +
                                                          +
                                                          1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                          2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                          3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                          4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                          5. 5VeröffentlichungListing abschließen
                                                          +
                                                          Auto-Fill · Vinted

                                                          Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                          • ✓ Titel
                                                          • ✓ Preis
                                                          • ✓ Kategorie
                                                          • ✓ Größe & Marke
                                                          • ✓ Beschreibung
                                                          • ✓ Bilder
                                                          ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                          +
                                                          +
                                                          + +
                                                          Listing-Daten prüfen & bearbeiten +
                                                          +
                                                          Titel
                                                          + +
                                                          +
                                                          +
                                                          Beschreibung
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          Preis
                                                          + +
                                                          +
                                                          +
                                                          Zustand
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          Marke
                                                          + +
                                                          +
                                                          +
                                                          Größe
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          Kategorie (Vinted)
                                                          +
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          Farbe
                                                          + +
                                                          +
                                                          +
                                                          AI-Kategorie (Original)
                                                          + +
                                                          +
                                                          +
                                                          +
                                                          Tags
                                                          + +
                                                          + +
                                                          + + +
                                                          + + +

                                                          Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                          +
                                                          + +
                                                          + Entwurf speichern oder veröffentlichen +
                                                          + + + + +
                                                          +
                                                          + +
                                                          + `; + + 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 = '
                                                          Keine Kategorie gefunden
                                                          '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                          +
                                                          ${esc(m.name)}
                                                          +
                                                          ${esc(m.path.slice(0, -1).join(' > '))}
                                                          +
                                                          + `).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 = '
                                                          '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                          `; + html += `
                                                          ${esc(main.name)}
                                                          `; + html += `
                                                          `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                          `; + html += `
                                                          ${esc(sub.name)}
                                                          `; + html += `
                                                          `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                          ${esc(leaf.name)}
                                                          `; + } + } + html += '
                                                          '; + } + } + html += '
                                                          '; + } + html += '
                                                          '; + 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 = ` +
                                                          +
                                                          +
                                                          ${esc(cat.name)}
                                                          +
                                                          ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                          +
                                                          + +
                                                          + `; + 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 = ` +
                                                          +
                                                          + Modus: ${modeText} +
                                                          +
                                                          +
                                                          +
                                                          + 0 / ${STATE.batchProgress.total} + 0% +
                                                          +
                                                          + +
                                                          +
                                                          + `; + 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 += `
                                                          • ${esc(title.substring(0, 30))}... ${step}
                                                          `; + 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 += `
                                                          ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                          `; + 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(); +})(); + diff --git a/extensions/edge/background.js b/extensions/edge/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extensions/edge/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extensions/edge/content.css b/extensions/edge/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/extensions/edge/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extensions/edge/content.js b/extensions/edge/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/extensions/edge/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                                                          + + + ... + + + + +
                                                          +
                                                          + + + +
                                                          +
                                                          + + + 0 ausgewählt +
                                                          +
                                                          +
                                                          +
                                                          +
                                                          Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                                          +
                                                          0offen
                                                          +
                                                          +
                                                            +
                                                            +
                                                            +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              Batch-Modus
                                                              + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                              +
                                                              +
                                                              Pause zwischen Artikeln (Sek.)
                                                              + +
                                                              +
                                                              +
                                                              Server URL
                                                              + +
                                                              +
                                                              + +
                                                              +
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              + +
                                                              + `; + 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 = ``; 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 = '
                                                            • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                            • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                            • +
                                                              + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                              +
                                                              ${statusHtml}Vinted
                                                              +
                                                              ${esc(l.title || 'Ohne Titel')}
                                                              +
                                                              ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                              +
                                                              ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                              +
                                                              +
                                                              ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                              +
                                                              + + + +
                                                              +
                                                            • `; + }).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 = '
                                                            • 📦Noch keine Artikel veröffentlicht.
                                                            • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                            • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                              +
                                                              ${esc(l.title || 'Ohne Titel')}
                                                              +
                                                              ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                              +
                                                              + Erledigt +
                                                            • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                              + + +
                                                              + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                              + Bereit +

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

                                                              + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                              Kategorie
                                                              ${esc(listing.category || 'Nicht gesetzt')}
                                                              Marke
                                                              ${esc(listing.brand || '–')}
                                                              Größe
                                                              ${esc(listing.size || '–')}
                                                              Zustand
                                                              ${esc(listing.condition || '–')}
                                                              +
                                                              +
                                                              +
                                                              ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                              + +
                                                              +

                                                              Veröffentlichen auf Vinted

                                                              +
                                                              +
                                                              1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                              2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                              3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                              4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                              5. 5VeröffentlichungListing abschließen
                                                              +
                                                              Auto-Fill · Vinted

                                                              Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                              • ✓ Titel
                                                              • ✓ Preis
                                                              • ✓ Kategorie
                                                              • ✓ Größe & Marke
                                                              • ✓ Beschreibung
                                                              • ✓ Bilder
                                                              ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                              +
                                                              +
                                                              + +
                                                              Listing-Daten prüfen & bearbeiten +
                                                              +
                                                              Titel
                                                              + +
                                                              +
                                                              +
                                                              Beschreibung
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              Preis
                                                              + +
                                                              +
                                                              +
                                                              Zustand
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              Marke
                                                              + +
                                                              +
                                                              +
                                                              Größe
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              Kategorie (Vinted)
                                                              +
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              Farbe
                                                              + +
                                                              +
                                                              +
                                                              AI-Kategorie (Original)
                                                              + +
                                                              +
                                                              +
                                                              +
                                                              Tags
                                                              + +
                                                              + +
                                                              + + +
                                                              + + +

                                                              Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                              +
                                                              + +
                                                              + Entwurf speichern oder veröffentlichen +
                                                              + + + + +
                                                              +
                                                              + +
                                                              + `; + + 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 = '
                                                              Keine Kategorie gefunden
                                                              '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                              +
                                                              ${esc(m.name)}
                                                              +
                                                              ${esc(m.path.slice(0, -1).join(' > '))}
                                                              +
                                                              + `).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 = '
                                                              '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                              `; + html += `
                                                              ${esc(main.name)}
                                                              `; + html += `
                                                              `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                              `; + html += `
                                                              ${esc(sub.name)}
                                                              `; + html += `
                                                              `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                              ${esc(leaf.name)}
                                                              `; + } + } + html += '
                                                              '; + } + } + html += '
                                                              '; + } + html += '
                                                              '; + 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 = ` +
                                                              +
                                                              +
                                                              ${esc(cat.name)}
                                                              +
                                                              ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                              +
                                                              + +
                                                              + `; + 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 = ` +
                                                              +
                                                              + Modus: ${modeText} +
                                                              +
                                                              +
                                                              +
                                                              + 0 / ${STATE.batchProgress.total} + 0% +
                                                              +
                                                              + +
                                                              +
                                                              + `; + 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 += `
                                                              • ${esc(title.substring(0, 30))}... ${step}
                                                              `; + 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 += `
                                                              ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                              `; + 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(); +})(); + diff --git a/extensions/edge/icon-128.png b/extensions/edge/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extensions/edge/icon-128.png differ diff --git a/extensions/edge/icon-16.png b/extensions/edge/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extensions/edge/icon-16.png differ diff --git a/extensions/edge/icon-32.png b/extensions/edge/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extensions/edge/icon-32.png differ diff --git a/extensions/edge/icon-48.png b/extensions/edge/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extensions/edge/icon-48.png differ diff --git a/extensions/edge/icon.svg b/extensions/edge/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extensions/edge/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/edge/manifest.json b/extensions/edge/manifest.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/extensions/edge/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/extensions/edge/popup.css b/extensions/edge/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/extensions/edge/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/extensions/edge/popup.html b/extensions/edge/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/extensions/edge/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                                              + + + + + + +
                                                              + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                                                              +
                                                              + + + diff --git a/extensions/edge/popup.js b/extensions/edge/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/extensions/edge/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                                              +
                                                              ${escapeHtml(item.title || 'Produktbild')}
                                                              +
                                                              +
                                                              +
                                                              ${escapeHtml(item.title || 'Ohne Titel')}
                                                              +
                                                              ${formatPrice(item.price)}
                                                              +
                                                              +
                                                              ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                                              +
                                                              ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                                              +
                                                              + + + + +
                                                              +
                                                              +
                                                              + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/extensions/edge/vinted-categories.js b/extensions/edge/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extensions/edge/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/extensions/firefox/background.js b/extensions/firefox/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extensions/firefox/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extensions/firefox/content.css b/extensions/firefox/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/extensions/firefox/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extensions/firefox/content.js b/extensions/firefox/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/extensions/firefox/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                                                              + + + ... + + + + +
                                                              +
                                                              + + + +
                                                              +
                                                              + + + 0 ausgewählt +
                                                              +
                                                              +
                                                              +
                                                              +
                                                              Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                                              +
                                                              0offen
                                                              +
                                                              +
                                                                +
                                                                +
                                                                +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  Batch-Modus
                                                                  + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                                  +
                                                                  +
                                                                  Pause zwischen Artikeln (Sek.)
                                                                  + +
                                                                  +
                                                                  +
                                                                  Server URL
                                                                  + +
                                                                  +
                                                                  + +
                                                                  +
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  + +
                                                                  + `; + 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 = ``; 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 = '
                                                                • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                                • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                                • +
                                                                  + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                                  +
                                                                  ${statusHtml}Vinted
                                                                  +
                                                                  ${esc(l.title || 'Ohne Titel')}
                                                                  +
                                                                  ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                                  +
                                                                  ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                                  +
                                                                  +
                                                                  ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                                  +
                                                                  + + + +
                                                                  +
                                                                • `; + }).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 = '
                                                                • 📦Noch keine Artikel veröffentlicht.
                                                                • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                                • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                                  +
                                                                  ${esc(l.title || 'Ohne Titel')}
                                                                  +
                                                                  ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                                  +
                                                                  + Erledigt +
                                                                • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                                  + + +
                                                                  + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                                  + Bereit +

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

                                                                  + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                                  Kategorie
                                                                  ${esc(listing.category || 'Nicht gesetzt')}
                                                                  Marke
                                                                  ${esc(listing.brand || '–')}
                                                                  Größe
                                                                  ${esc(listing.size || '–')}
                                                                  Zustand
                                                                  ${esc(listing.condition || '–')}
                                                                  +
                                                                  +
                                                                  +
                                                                  ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                                  + +
                                                                  +

                                                                  Veröffentlichen auf Vinted

                                                                  +
                                                                  +
                                                                  1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                                  2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                                  3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                                  4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                                  5. 5VeröffentlichungListing abschließen
                                                                  +
                                                                  Auto-Fill · Vinted

                                                                  Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                                  • ✓ Titel
                                                                  • ✓ Preis
                                                                  • ✓ Kategorie
                                                                  • ✓ Größe & Marke
                                                                  • ✓ Beschreibung
                                                                  • ✓ Bilder
                                                                  ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                                  +
                                                                  +
                                                                  + +
                                                                  Listing-Daten prüfen & bearbeiten +
                                                                  +
                                                                  Titel
                                                                  + +
                                                                  +
                                                                  +
                                                                  Beschreibung
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  Preis
                                                                  + +
                                                                  +
                                                                  +
                                                                  Zustand
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  Marke
                                                                  + +
                                                                  +
                                                                  +
                                                                  Größe
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  Kategorie (Vinted)
                                                                  +
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  Farbe
                                                                  + +
                                                                  +
                                                                  +
                                                                  AI-Kategorie (Original)
                                                                  + +
                                                                  +
                                                                  +
                                                                  +
                                                                  Tags
                                                                  + +
                                                                  + +
                                                                  + + +
                                                                  + + +

                                                                  Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                                  +
                                                                  + +
                                                                  + Entwurf speichern oder veröffentlichen +
                                                                  + + + + +
                                                                  +
                                                                  + +
                                                                  + `; + + 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 = '
                                                                  Keine Kategorie gefunden
                                                                  '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                                  +
                                                                  ${esc(m.name)}
                                                                  +
                                                                  ${esc(m.path.slice(0, -1).join(' > '))}
                                                                  +
                                                                  + `).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 = '
                                                                  '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                                  `; + html += `
                                                                  ${esc(main.name)}
                                                                  `; + html += `
                                                                  `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                                  `; + html += `
                                                                  ${esc(sub.name)}
                                                                  `; + html += `
                                                                  `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                                  ${esc(leaf.name)}
                                                                  `; + } + } + html += '
                                                                  '; + } + } + html += '
                                                                  '; + } + html += '
                                                                  '; + 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 = ` +
                                                                  +
                                                                  +
                                                                  ${esc(cat.name)}
                                                                  +
                                                                  ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                                  +
                                                                  + +
                                                                  + `; + 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 = ` +
                                                                  +
                                                                  + Modus: ${modeText} +
                                                                  +
                                                                  +
                                                                  +
                                                                  + 0 / ${STATE.batchProgress.total} + 0% +
                                                                  +
                                                                  + +
                                                                  +
                                                                  + `; + 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 += `
                                                                  • ${esc(title.substring(0, 30))}... ${step}
                                                                  `; + 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 += `
                                                                  ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                                  `; + 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(); +})(); + diff --git a/extensions/firefox/icon-128.png b/extensions/firefox/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extensions/firefox/icon-128.png differ diff --git a/extensions/firefox/icon-16.png b/extensions/firefox/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extensions/firefox/icon-16.png differ diff --git a/extensions/firefox/icon-32.png b/extensions/firefox/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extensions/firefox/icon-32.png differ diff --git a/extensions/firefox/icon-48.png b/extensions/firefox/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extensions/firefox/icon-48.png differ diff --git a/extensions/firefox/icon.svg b/extensions/firefox/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extensions/firefox/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/firefox/manifest.json b/extensions/firefox/manifest.json new file mode 100644 index 0000000..69e18c4 --- /dev/null +++ b/extensions/firefox/manifest.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} diff --git a/extensions/firefox/popup.css b/extensions/firefox/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/extensions/firefox/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/extensions/firefox/popup.html b/extensions/firefox/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/extensions/firefox/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                                                  + + + + + + +
                                                                  + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                                                                  +
                                                                  + + + diff --git a/extensions/firefox/popup.js b/extensions/firefox/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/extensions/firefox/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                                                  +
                                                                  ${escapeHtml(item.title || 'Produktbild')}
                                                                  +
                                                                  +
                                                                  +
                                                                  ${escapeHtml(item.title || 'Ohne Titel')}
                                                                  +
                                                                  ${formatPrice(item.price)}
                                                                  +
                                                                  +
                                                                  ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                                                  +
                                                                  ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                                                  +
                                                                  + + + + +
                                                                  +
                                                                  +
                                                                  + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/extensions/firefox/vinted-categories.js b/extensions/firefox/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extensions/firefox/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/extensions/icon-128.png b/extensions/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extensions/icon-128.png differ diff --git a/extensions/icon-16.png b/extensions/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extensions/icon-16.png differ diff --git a/extensions/icon-32.png b/extensions/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extensions/icon-32.png differ diff --git a/extensions/icon-48.png b/extensions/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extensions/icon-48.png differ diff --git a/extensions/icon.svg b/extensions/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extensions/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/manifest.chromium.json b/extensions/manifest.chromium.json new file mode 100644 index 0000000..36f01b1 --- /dev/null +++ b/extensions/manifest.chromium.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/extensions/manifest.firefox.json b/extensions/manifest.firefox.json new file mode 100644 index 0000000..69e18c4 --- /dev/null +++ b/extensions/manifest.firefox.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "name": "Vendoo Link – Vinted Assistant", + "version": "1.9.0", + "description": "Vendoo Link mit sicherem Vinted-Auto-Fill, eindeutiger Feldzuordnung, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage", + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "vendoo-link@vendoo.local", + "strict_min_version": "109.0" + } + } +} diff --git a/extensions/packages/vendoo-link-chrome-1.7.0.zip b/extensions/packages/vendoo-link-chrome-1.7.0.zip new file mode 100644 index 0000000..165f7ec Binary files /dev/null and b/extensions/packages/vendoo-link-chrome-1.7.0.zip differ diff --git a/extensions/packages/vendoo-link-chrome-1.9.0.zip b/extensions/packages/vendoo-link-chrome-1.9.0.zip new file mode 100644 index 0000000..b0a83c5 Binary files /dev/null and b/extensions/packages/vendoo-link-chrome-1.9.0.zip differ diff --git a/extensions/packages/vendoo-link-edge-1.7.0.zip b/extensions/packages/vendoo-link-edge-1.7.0.zip new file mode 100644 index 0000000..1bb73c1 Binary files /dev/null and b/extensions/packages/vendoo-link-edge-1.7.0.zip differ diff --git a/extensions/packages/vendoo-link-edge-1.9.0.zip b/extensions/packages/vendoo-link-edge-1.9.0.zip new file mode 100644 index 0000000..b0a83c5 Binary files /dev/null and b/extensions/packages/vendoo-link-edge-1.9.0.zip differ diff --git a/extensions/packages/vendoo-link-firefox-1.7.0.zip b/extensions/packages/vendoo-link-firefox-1.7.0.zip new file mode 100644 index 0000000..6d72e3b Binary files /dev/null and b/extensions/packages/vendoo-link-firefox-1.7.0.zip differ diff --git a/extensions/packages/vendoo-link-firefox-1.9.0.zip b/extensions/packages/vendoo-link-firefox-1.9.0.zip new file mode 100644 index 0000000..54a16f4 Binary files /dev/null and b/extensions/packages/vendoo-link-firefox-1.9.0.zip differ diff --git a/extensions/packages/vendoo-link-safari-1.7.0.zip b/extensions/packages/vendoo-link-safari-1.7.0.zip new file mode 100644 index 0000000..180400d Binary files /dev/null and b/extensions/packages/vendoo-link-safari-1.7.0.zip differ diff --git a/extensions/packages/vendoo-link-safari-1.9.0.zip b/extensions/packages/vendoo-link-safari-1.9.0.zip new file mode 100644 index 0000000..0f701e7 Binary files /dev/null and b/extensions/packages/vendoo-link-safari-1.9.0.zip differ diff --git a/extensions/popup.css b/extensions/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/extensions/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/extensions/popup.html b/extensions/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/extensions/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                                                  + + + + + + +
                                                                  + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                                                                  +
                                                                  + + + diff --git a/extensions/popup.js b/extensions/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/extensions/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                                                  +
                                                                  ${escapeHtml(item.title || 'Produktbild')}
                                                                  +
                                                                  +
                                                                  +
                                                                  ${escapeHtml(item.title || 'Ohne Titel')}
                                                                  +
                                                                  ${formatPrice(item.price)}
                                                                  +
                                                                  +
                                                                  ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                                                  +
                                                                  ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                                                  +
                                                                  + + + + +
                                                                  +
                                                                  +
                                                                  + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/extensions/safari/README_SAFARI.md b/extensions/safari/README_SAFARI.md new file mode 100644 index 0000000..d6ac48d --- /dev/null +++ b/extensions/safari/README_SAFARI.md @@ -0,0 +1,13 @@ +# Vendoo Link für Safari + +Safari-WebExtensions müssen von Apple als macOS-App verpackt und signiert werden. Die Quelle liegt in `web-extension`. + +## Erstellen + +1. Auf einem Mac Xcode installieren. +2. `build-safari-extension.command` per Doppelklick starten. +3. Das erzeugte Xcode-Projekt öffnen, ein lokales Development Team auswählen und die App starten. +4. Safari > Einstellungen > Erweitert: Entwicklerfunktionen aktivieren. +5. Safari > Einstellungen > Erweiterungen: Vendoo Link aktivieren. + +Eine stille automatische Installation ist bei Safari nicht möglich. diff --git a/extensions/safari/build-safari-extension.command b/extensions/safari/build-safari-extension.command new file mode 100644 index 0000000..b79e0d2 --- /dev/null +++ b/extensions/safari/build-safari-extension.command @@ -0,0 +1,16 @@ +#!/bin/zsh +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if ! command -v xcrun >/dev/null 2>&1; then + echo "Xcode Command Line Tools fehlen. Bitte Xcode installieren." + read -k 1 + exit 1 +fi +OUT="$SCRIPT_DIR/build" +mkdir -p "$OUT" +xcrun safari-web-extension-converter "$SCRIPT_DIR/web-extension" \ + --project-location "$OUT" \ + --app-name "Vendoo Link" \ + --bundle-identifier "local.vendoo.link" \ + --copy-resources +open "$OUT" diff --git a/extensions/safari/web-extension/background.js b/extensions/safari/web-extension/background.js new file mode 100644 index 0000000..622d35c --- /dev/null +++ b/extensions/safari/web-extension/background.js @@ -0,0 +1,60 @@ +const ext = globalThis.browser || globalThis.chrome; +const csrfCache = new Map(); + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +async function getCsrf(serverUrl) { + if (csrfCache.has(serverUrl)) return csrfCache.get(serverUrl); + const response = await fetch(`${serverUrl}/api/me`, { credentials: 'include', cache: 'no-store' }); + if (!response.ok) return ''; + const data = await response.json(); + const token = data.csrf || ''; + csrfCache.set(serverUrl, token); + return token; +} + +ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'api') { + (async () => { + try { + const { serverUrl, path, method = 'GET', body } = msg; + const opts = { method, headers: {}, credentials: 'include', cache: 'no-store' }; + if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + } + let response = await fetch(`${serverUrl}${path}`, opts); + if (response.status === 403 && !['GET', 'HEAD', 'OPTIONS'].includes(method)) { + csrfCache.delete(serverUrl); + const csrf = await getCsrf(serverUrl); + if (csrf) opts.headers['X-CSRF-Token'] = csrf; + response = await fetch(`${serverUrl}${path}`, opts); + } + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') ? await response.json() : { error: await response.text() }; + sendResponse({ ok: response.ok, status: response.status, data }); + } catch (error) { sendResponse({ ok: false, status: 0, error: error.message }); } + })(); + return true; + } + if (msg.type === 'fetch-image') { + (async () => { + try { + const response = await fetch(msg.url, { cache: 'no-store', credentials: 'include' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Antwort ist kein Bild'); + const buffer = await blob.arrayBuffer(); + sendResponse({ ok: true, dataUrl: `data:${blob.type};base64,${arrayBufferToBase64(buffer)}`, mime: blob.type, size: blob.size }); + } catch (error) { sendResponse({ ok: false, error: error.message }); } + })(); + return true; + } +}); diff --git a/extensions/safari/web-extension/content.css b/extensions/safari/web-extension/content.css new file mode 100644 index 0000000..10b2e80 --- /dev/null +++ b/extensions/safari/web-extension/content.css @@ -0,0 +1,534 @@ +:root { + --al-bg: #f3f0ea; + --al-surface: #fffdf9; + --al-surface-strong: #ffffff; + --al-surface-muted: #ece8e0; + --al-line: #ddd7cd; + --al-line-strong: #c8c0b4; + --al-text: #25231f; + --al-text-secondary: #6f6a62; + --al-text-muted: #999187; + --al-brand: #e33a16; + --al-brand-strong: #c92f10; + --al-brand-soft: rgba(227, 58, 22, .09); + --al-success: #23855b; + --al-success-soft: rgba(35, 133, 91, .1); + --al-warning: #b97817; + --al-warning-soft: rgba(185, 120, 23, .11); + --al-danger: #c43f35; + --al-danger-soft: rgba(196, 63, 53, .1); + --al-radius-lg: 18px; + --al-radius-md: 12px; + --al-radius-sm: 8px; + --al-shadow: 0 24px 70px rgba(35, 31, 25, .22), 0 2px 8px rgba(35, 31, 25, .08); +} + +#al-panel { + position: fixed; + top: 72px; + right: 18px; + width: 404px; + height: 640px; + min-width: 340px; + min-height: 420px; + z-index: 2147483000; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(201, 193, 181, .9); + border-radius: var(--al-radius-lg); + background: var(--al-bg); + color: var(--al-text); + box-shadow: var(--al-shadow); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + isolation: isolate; +} +#al-panel * { box-sizing: border-box; margin: 0; padding: 0; } +#al-panel button, +#al-panel input, +#al-panel select, +#al-panel textarea { font: inherit; } + +.al-resize-handle { + position: absolute; + right: 4px; + bottom: 4px; + width: 18px; + height: 18px; + z-index: 20; + cursor: nwse-resize; + opacity: .45; + background: linear-gradient(135deg, transparent 54%, var(--al-line-strong) 55%, var(--al-line-strong) 60%, transparent 61%, transparent 70%, var(--al-line-strong) 71%, var(--al-line-strong) 76%, transparent 77%); +} + +.al-hd { + display: flex; + align-items: center; + gap: 8px; + min-height: 58px; + padding: 10px 12px 10px 15px; + flex: 0 0 auto; + cursor: move; + user-select: none; + border-bottom: 1px solid var(--al-line); + background: rgba(255, 253, 249, .96); + backdrop-filter: blur(16px); +} +.al-hd-logo { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 800; + letter-spacing: -.04em; +} +.al-hd-logo::before { + content: "V"; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px 4px 9px 4px; + background: var(--al-brand); + color: #fff; + font-size: 13px; + font-weight: 900; + transform: rotate(-3deg); +} +.al-hd-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + color: var(--al-text-secondary); + background: var(--al-surface-muted); + font-size: 9px; + font-weight: 750; +} +.al-hd-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--al-text-muted); +} +.al-hd-status.live { color: var(--al-success); background: var(--al-success-soft); } +.al-hd-status.live::before { background: var(--al-success); box-shadow: 0 0 0 3px rgba(35, 133, 91, .1); } +.al-hd-status.off { color: var(--al-danger); background: var(--al-danger-soft); } +.al-hd-status.off::before { background: var(--al-danger); } +.al-hd-sep { flex: 1; } +.al-hd button { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--al-text-secondary); + font-size: 17px; + line-height: 1; + cursor: pointer; +} +.al-hd button:hover { border-color: var(--al-line); background: var(--al-surface-muted); color: var(--al-text); } + +.al-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + flex: 0 0 auto; + padding: 5px; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface); +} +.al-tab { + min-height: 36px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--al-text-muted); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} +.al-tab:hover { color: var(--al-text); } +.al-tab.active { background: var(--al-brand-soft); color: var(--al-brand); box-shadow: inset 0 0 0 1px rgba(227, 58, 22, .12); } + +.al-batch-bar { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 12px; + flex: 0 0 auto; + border-bottom: 1px solid var(--al-line); + background: var(--al-surface-strong); +} +.al-batch-bar label { color: var(--al-text-secondary) !important; font-size: 10px !important; font-weight: 700 !important; } +.al-batch-bar .al-count { margin-left: auto; color: var(--al-brand); font-size: 10px; font-weight: 800; } +.al-cb { width: 16px; height: 16px; accent-color: var(--al-brand); cursor: pointer; } + +.al-body { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--al-line-strong) transparent; +} +.al-body::-webkit-scrollbar { width: 6px; } +.al-body::-webkit-scrollbar-track { background: transparent; } +.al-body::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--al-line-strong); } +.al-view { display: none; } +.al-view.active { display: block; } +.al-list { list-style: none; padding: 8px; } + +.al-item { + position: relative; + display: grid; + grid-template-columns: auto 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 72px; + padding: 9px; + margin-bottom: 6px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--al-radius-md); + background: var(--al-surface-strong); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease, background .16s ease; +} +.al-item:hover { transform: translateY(-1px); border-color: var(--al-line); box-shadow: 0 8px 24px rgba(35, 31, 25, .08); } +.al-item.selected { border-color: rgba(227, 58, 22, .34); background: linear-gradient(90deg, var(--al-brand-soft), var(--al-surface-strong) 36%); } +.al-item.processing { border-color: rgba(185, 120, 23, .38); background: linear-gradient(90deg, var(--al-warning-soft), var(--al-surface-strong) 42%); } +.al-item.done { opacity: .68; } +.al-item-cb { align-self: start; margin-top: 4px; } +.al-item-img, +.al-image-placeholder { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-image-placeholder::before { content: "□"; color: var(--al-text-muted); font-size: 19px; } +.al-image-loading { opacity: .45; background: linear-gradient(100deg, #ece8e0 25%, #faf8f4 45%, #ece8e0 65%); background-size: 200% 100%; animation: al-shimmer 1.2s linear infinite; } +.al-image-ready { opacity: 1; animation: al-image-in .18s ease both; } +.al-image-error { position: relative; color: transparent; background: var(--al-danger-soft); } +.al-image-error::after { content: "Bild fehlt"; color: var(--al-danger); font-size: 7px; font-weight: 800; } +@keyframes al-shimmer { to { background-position: -200% 0; } } +@keyframes al-image-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.al-item-info { min-width: 0; } +.al-item-title { overflow: hidden; color: var(--al-text); font-size: 11px; font-weight: 750; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-meta { margin-top: 4px; overflow: hidden; color: var(--al-text-muted); font-size: 9px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.al-item-price { align-self: start; padding-top: 2px; color: var(--al-text); font-size: 10px; font-weight: 800; } +.al-item-status { + position: absolute; + right: 8px; + bottom: 7px; + padding: 3px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; +} +.al-item-status.pending { color: var(--al-warning); background: var(--al-warning-soft); } +.al-item-status.running { color: var(--al-success); background: var(--al-success-soft); animation: al-pulse 1.4s ease-in-out infinite; } +.al-item-status.done { color: var(--al-success); background: var(--al-success-soft); } +.al-item-status.error { color: var(--al-danger); background: var(--al-danger-soft); } +@keyframes al-pulse { 50% { opacity: .55; } } + +.al-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 46px 22px; + color: var(--al-text-muted); + font-size: 11px; + line-height: 1.55; + text-align: center; +} +.al-empty-icon { width: 42px; height: 42px; display: grid; place-items: center; margin-bottom: 5px; border-radius: 50%; background: var(--al-success-soft); color: var(--al-success); font-size: 20px; } + +.al-detail { padding: 12px; } +.al-detail-back { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 10px; + border: 0; + background: transparent; + color: var(--al-brand); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} +.al-detail-photos { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.al-detail-photo { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--al-line); + border-radius: 9px; + background: linear-gradient(145deg, #fff, #ece8e0); + object-fit: contain; + object-position: center; + padding: 4px; +} +.al-field { margin-bottom: 10px; } +.al-field-label, +.al-setting-label { + margin-bottom: 5px; + color: var(--al-text-secondary); + font-size: 9px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} +.al-field input, +.al-field textarea, +.al-select, +.al-input, +.al-cat-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--al-line); + border-radius: 9px; + outline: 0; + background: var(--al-surface-strong); + color: var(--al-text); + font-size: 11px; +} +.al-field textarea { min-height: 88px; resize: vertical; line-height: 1.5; } +.al-field input:focus, +.al-field textarea:focus, +.al-select:focus, +.al-input:focus, +.al-cat-input:focus { border-color: var(--al-brand); box-shadow: 0 0 0 3px var(--al-brand-soft); } +.al-field-readonly { opacity: .65; } +.al-meta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.al-detail-section-label { margin: 16px 0 8px; padding-top: 12px; border-top: 1px solid var(--al-line); color: var(--al-text-muted); font-size: 8px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } + +.al-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: transform .14s ease, background .14s ease, border-color .14s ease; +} +.al-btn:hover:not(:disabled) { transform: translateY(-1px); } +.al-btn:disabled { opacity: .4; cursor: not-allowed; } +.al-btn-primary { color: #fff; background: var(--al-text); } +.al-btn-primary:hover { background: #11110f; } +.al-btn-fill { color: #fff; background: var(--al-brand); box-shadow: 0 8px 20px rgba(227, 58, 22, .16); } +.al-btn-fill:hover { background: var(--al-brand-strong); } +.al-btn-ghost { border-color: var(--al-line); background: var(--al-surface-strong); color: var(--al-text); } +.al-btn-ghost:hover { border-color: var(--al-line-strong); } +.al-btn-danger { color: #fff; background: var(--al-danger); } +.al-btn-sm { min-height: 32px; padding: 6px 9px; } +.al-btn-full { width: 100%; } +.al-btn-action { justify-content: flex-start; margin-bottom: 6px; } +.al-btn-icon { width: 20px; color: var(--al-brand); text-align: center; } + +.al-footer { flex: 0 0 auto; padding: 10px; border-top: 1px solid var(--al-line); background: rgba(255, 253, 249, .96); backdrop-filter: blur(14px); } +.al-footer-row { display: flex; align-items: center; gap: 7px; } +.al-footer-row + .al-footer-row { margin-top: 7px; } + +.al-settings { padding: 14px; } +.al-setting { margin-bottom: 13px; } + +.al-progress { margin-bottom: 10px; } +.al-progress-bar { height: 7px; overflow: hidden; border-radius: 999px; background: var(--al-surface-muted); } +.al-progress-fill { height: 100%; border-radius: inherit; background: var(--al-brand); transition: width .3s ease; } +.al-progress-text { display: flex; justify-content: space-between; margin-top: 5px; color: var(--al-text-muted); font-size: 9px; } +.al-countdown { padding: 10px; color: var(--al-brand); font-size: 22px; font-weight: 850; text-align: center; font-variant-numeric: tabular-nums; } +.al-countdown-label { color: var(--al-text-muted); font-size: 9px; font-weight: 650; } + +.al-cat-wrap { position: relative; } +.al-cat-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + right: 0; + z-index: 50; + display: none; + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--al-line); + border-radius: 10px; + background: var(--al-surface-strong); + box-shadow: 0 14px 36px rgba(35, 31, 25, .16); +} +.al-cat-dropdown.open { display: block; } +.al-cat-item { padding: 8px 10px; border-bottom: 1px solid var(--al-line); cursor: pointer; } +.al-cat-item:hover, +.al-cat-item.active { background: var(--al-brand-soft); } +.al-cat-item-name { color: var(--al-text); font-size: 10px; font-weight: 750; } +.al-cat-item-path { margin-top: 2px; color: var(--al-text-muted); font-size: 8px; } +.al-cat-selected { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 7px 9px; border: 1px solid rgba(227, 58, 22, .25); border-radius: 8px; background: var(--al-brand-soft); color: var(--al-brand); font-size: 9px; font-weight: 750; } +.al-cat-clear { margin-left: auto; border: 0; background: transparent; color: var(--al-text-muted); cursor: pointer; } +.al-cat-breadcrumb { color: var(--al-text-muted); font-size: 8px; } +.al-cat-group { border-bottom: 1px solid var(--al-line); } +.al-cat-group-hd { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--al-surface); color: var(--al-text); font-size: 10px; font-weight: 800; cursor: pointer; } +.al-cat-group-hd:hover { background: var(--al-brand-soft); } +.al-cat-arrow { color: var(--al-text-muted); font-size: 8px; transition: transform .15s ease; } +.al-cat-group-hd.expanded .al-cat-arrow { transform: rotate(90deg); } +.al-cat-group-children { display: none; padding-left: 9px; } +.al-cat-group-children.open { display: block; } +.al-cat-leaf { padding: 7px 10px; color: var(--al-text-secondary); font-size: 9px; cursor: pointer; } +.al-cat-leaf:hover { color: var(--al-brand); background: var(--al-brand-soft); } + +.al-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2147483647; + max-width: 340px; + padding: 11px 14px; + border-radius: 10px; + background: #25231f; + color: #fff; + box-shadow: 0 16px 42px rgba(0, 0, 0, .22); + font: 700 11px/1.4 Inter, ui-sans-serif, system-ui, sans-serif; + opacity: 0; + transform: translateY(12px); + transition: opacity .2s ease, transform .2s ease; +} +.al-toast.show { opacity: 1; transform: translateY(0); } +.al-toast.success { background: var(--al-success); } +.al-toast.error { background: var(--al-danger); } +.al-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 2147483000; + width: 52px; + height: 52px; + border: 0; + border-radius: 17px 7px 17px 7px; + background: var(--al-brand); + color: #fff; + box-shadow: 0 16px 36px rgba(227, 58, 22, .28); + font: 900 13px Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} +.al-fab:hover { background: var(--al-brand-strong); transform: translateY(-2px); } +.al-spinner { width: 14px; height: 14px; display: inline-block; border: 2px solid rgba(255, 255, 255, .35); border-top-color: #fff; border-radius: 50%; animation: al-spin .65s linear infinite; } +@keyframes al-spin { to { transform: rotate(360deg); } } + +@media (max-width: 720px) { + #al-panel { top: 10px; right: 10px; width: calc(100vw - 20px); height: min(700px, calc(100vh - 20px)); min-width: 0; border-radius: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + #al-panel *, .al-toast, .al-fab { animation: none !important; transition: none !important; } +} + +/* Vendoo Assistant 1.3 visual system */ +#al-panel{top:14px;right:14px;width:min(640px,calc(100vw - 28px));height:min(820px,calc(100vh - 28px));min-width:360px;min-height:480px;border-radius:18px;background:#f7f3ec;box-shadow:0 28px 90px rgba(32,27,22,.24),0 2px 12px rgba(32,27,22,.09)} +.al-hd{min-height:64px;padding:10px 13px;background:rgba(255,253,249,.97);cursor:default}.al-hd-logo::before{display:none}.al-hd-logo{font-size:15px;white-space:nowrap}.al-hd-brand-mark{position:relative;width:34px;height:34px;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:5px;top:5px;width:8px;height:25px;border-radius:5px;background:var(--al-brand);transform:rotate(-30deg)}.al-hd-brand-mark b{position:absolute;left:18px;top:4px;width:14px;height:16px;border-radius:4px 7px 7px 5px;background:var(--al-brand);transform:rotate(14deg)}.al-hd-brand-mark b::after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px;font-style:normal}.al-hd-status{margin-left:auto}.al-hd-sep{flex:0}.al-tabs{grid-template-columns:repeat(3,minmax(0,1fr));padding:6px 10px;background:#fffdf9}.al-tab{min-height:34px}.al-batch-bar{padding-inline:14px}.al-list{padding:10px}.al-item{grid-template-columns:auto 62px minmax(0,1fr) auto;min-height:82px;padding:8px;border:1px solid transparent;border-radius:12px;background:#fffdf9;margin-bottom:7px}.al-item:hover{border-color:var(--al-line);box-shadow:0 7px 20px rgba(35,31,25,.06)}.al-item.selected{border-color:rgba(227,58,22,.35);background:var(--al-brand-soft)}.al-item-img{width:62px;height:62px;border-radius:9px;padding:4px;object-fit:contain}.al-item-title{font-size:11px}.al-item-meta{white-space:normal}.al-item-price{font-size:11px}.al-footer{padding:10px 12px}.al-btn{min-height:40px;border-radius:9px}.al-btn-fill{background:var(--al-brand)}.al-btn-primary{background:#2a2926}.al-detail{padding:12px 14px 18px}.al-detail-back{margin-bottom:12px}.al-detail-hero{display:grid;grid-template-columns:minmax(250px,1.15fr) minmax(190px,.85fr);gap:16px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-detail-main-photo{width:100%;height:300px;object-fit:contain;object-position:center;padding:10px;border-radius:11px;background:linear-gradient(145deg,#fff,#eee9e1)}.al-detail-summary{min-width:0;padding:8px 4px}.al-detail-ready{display:inline-flex;padding:4px 8px;border-radius:999px;background:var(--al-success-soft);color:var(--al-success);font-size:8px;font-weight:800}.al-detail-summary h2{margin:12px 0 5px;font-size:21px;line-height:1.12;letter-spacing:-.04em}.al-detail-summary>strong{display:block;margin-bottom:14px;font-size:18px}.al-detail-summary dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 7px;font-size:9px}.al-detail-summary dt{color:var(--al-text-muted)}.al-detail-summary dd{margin:0;font-weight:650;overflow-wrap:anywhere}.al-detail-photos{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;margin:8px 0 14px}.al-detail-photo{aspect-ratio:1;height:auto}.al-publish-flow{padding:13px;border:1px solid var(--al-line);border-radius:14px;background:#fffdf9}.al-publish-flow>h3{margin:0 0 11px;font-size:13px}.al-flow-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.al-flow-grid ol{display:grid;gap:8px;list-style:none}.al-flow-grid li{display:grid;grid-template-columns:24px minmax(0,1fr) 18px;align-items:center;gap:8px}.al-flow-grid li>b{width:22px;height:22px;display:grid;place-items:center;border-radius:50%;background:#e9e4dc;color:var(--al-text-muted);font-size:8px}.al-flow-grid li.done>b{background:var(--al-success);color:#fff}.al-flow-grid li.active>b{background:var(--al-brand);color:#fff}.al-flow-grid li span{display:grid;gap:1px}.al-flow-grid li strong{font-size:9px}.al-flow-grid li small{color:var(--al-text-muted);font-size:7px}.al-flow-grid li em{font-style:normal;color:var(--al-text-muted)}.al-flow-grid li.done em{color:var(--al-success)}.al-autofill-card{padding:11px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface)}.al-autofill-card>span{font-size:9px;font-weight:800}.al-autofill-card>p{margin:4px 0 8px;color:var(--al-text-muted);font-size:7px;line-height:1.45}.al-autofill-card ul{display:grid;grid-template-columns:1fr 1fr;gap:5px;list-style:none;color:var(--al-success);font-size:8px}.al-autofill-card>div{margin-top:10px;padding:8px;border-radius:8px;background:var(--al-success-soft);color:var(--al-success)}.al-autofill-card>div strong,.al-autofill-card>div small{display:block}.al-autofill-card>div strong{font-size:8px}.al-autofill-card>div small{margin-top:2px;font-size:7px}.al-edit-details{margin-top:12px;border:1px solid var(--al-line);border-radius:12px;background:#fffdf9;overflow:hidden}.al-edit-details summary{padding:12px 13px;cursor:pointer;font-size:10px;font-weight:800}.al-edit-details[open] summary{border-bottom:1px solid var(--al-line)}.al-edit-details>.al-field,.al-edit-details>.al-meta-row{margin:10px 12px}.al-detail-section-label{margin-top:14px}.al-fab{width:52px;height:52px;border:0;border-radius:16px;background:#fffdf9;box-shadow:0 14px 38px rgba(35,31,25,.22);position:fixed;right:18px;bottom:18px;z-index:2147483000;color:var(--al-brand);cursor:pointer}.al-fab-mark{font-size:18px;font-weight:900}.al-fab b{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;border-radius:50%;background:#2d2c29;color:#fff;font-size:8px} +@media(max-width:760px){#al-panel{inset:8px;width:auto;height:auto;min-width:0;min-height:0}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:250px}.al-flow-grid{grid-template-columns:1fr}.al-detail-photos{grid-template-columns:repeat(5,minmax(0,1fr))}.al-hd-logo{font-size:13px}} + + +/* Slice 08 – compact, action-first extension redesign */ +#al-panel{top:18px;right:18px;width:min(438px,calc(100vw - 36px));height:min(720px,calc(100vh - 36px));min-width:340px;min-height:480px;border-radius:20px;background:#f4f0e9;box-shadow:0 28px 90px rgba(35,31,25,.22),0 3px 12px rgba(35,31,25,.09)} +.al-hd{min-height:64px;padding:11px 13px 11px 16px}.al-hd-brand-mark{position:relative;width:31px;height:31px;display:block;flex:0 0 auto}.al-hd-brand-mark i{position:absolute;left:4px;top:4px;width:9px;height:24px;border-radius:8px;background:var(--al-brand);transform:rotate(-31deg)}.al-hd-brand-mark b{position:absolute;right:3px;top:4px;width:12px;height:12px;border-radius:3px 6px 6px 6px;background:var(--al-brand);transform:rotate(18deg)}.al-hd-brand-mark em{position:absolute;right:0;bottom:0;width:13px;height:13px;display:grid;place-items:center;border-radius:50%;background:#2c2a27;color:#fff;font:800 8px/1 sans-serif}.al-hd-logo{font-size:14px}.al-hd-logo::before{display:none}.al-tabs{padding:6px 9px;gap:5px}.al-tab{min-height:35px}.al-batch-bar{min-height:40px;padding:7px 13px}.al-body{background:linear-gradient(180deg,rgba(255,253,249,.7),rgba(244,240,233,.5))}.al-queue-overview{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;margin:10px 10px 4px;padding:13px 14px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong);box-shadow:0 8px 22px rgba(35,31,25,.045)}.al-queue-overview>div:first-child{display:grid;gap:2px}.al-queue-overview span{color:var(--al-brand);font-size:8px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.al-queue-overview strong{font-size:13px;letter-spacing:-.02em}.al-queue-overview small{color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-queue-number{min-width:54px;padding:8px 9px;border-radius:11px;background:var(--al-brand-soft);text-align:center}.al-queue-number b{display:block;color:var(--al-brand);font-size:18px;line-height:1}.al-queue-number span{display:block;margin-top:4px;color:var(--al-text-muted);font-size:7px}.al-list{padding:7px 10px 12px}.al-item{grid-template-columns:20px 68px minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:108px;padding:10px;margin-bottom:8px;border-color:var(--al-line);border-radius:14px;cursor:default}.al-item:hover{transform:none;border-color:var(--al-line-strong);box-shadow:0 10px 28px rgba(35,31,25,.07)}.al-item-select{grid-row:1;align-self:start;padding-top:3px}.al-item-img,.al-image-placeholder{grid-row:1;width:68px;height:76px;border-radius:11px;padding:5px}.al-item-info{grid-row:1}.al-item-topline{display:flex;align-items:center;gap:6px;margin-bottom:5px}.al-item-topline>span:last-child{color:var(--al-text-muted);font-size:7px;font-weight:750}.al-item-title{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:11px;line-height:1.32}.al-item-meta{white-space:normal;font-size:8px}.al-item-sku{margin-top:5px;color:var(--al-text-muted);font:700 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}.al-item-side{grid-row:1;display:grid;justify-items:end;align-self:stretch;align-content:space-between}.al-item-side strong{font-size:10px;white-space:nowrap}.al-item-open{width:27px;height:27px;border:1px solid var(--al-line);border-radius:8px;background:var(--al-surface);color:var(--al-text-secondary);font-size:18px;cursor:pointer}.al-item-actions{grid-column:2/-1;grid-row:2;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}.al-quick-fill,.al-quick-detail{min-height:32px;border-radius:8px;font:800 9px/1 sans-serif;cursor:pointer}.al-quick-fill{border:0;background:var(--al-brand);color:#fff}.al-quick-fill:hover{background:var(--al-brand-strong)}.al-quick-detail{padding:0 12px;border:1px solid var(--al-line);background:var(--al-surface);color:var(--al-text)}.al-item-status{position:static!important;display:inline-flex!important;padding:3px 6px!important;border-radius:999px!important;font-size:7px!important}.al-item-status.ready{color:var(--al-success);background:var(--al-success-soft)}.al-footer{position:relative;padding:10px 11px}.al-footer-row{position:relative}.al-footer-empty{width:100%;display:grid;justify-items:center;gap:2px;padding:2px;color:var(--al-text-secondary)}.al-footer-empty span{font-size:10px;font-weight:800}.al-footer-empty small{font-size:8px}.al-footer-note{flex:1;min-width:0;display:grid;gap:1px}.al-footer-note strong{font-size:9px}.al-footer-note span{color:var(--al-text-muted);font-size:7px}.al-footer-menu{position:absolute;right:0;bottom:46px;z-index:30;min-width:190px;padding:5px;border:1px solid var(--al-line);border-radius:10px;background:var(--al-surface-strong);box-shadow:0 16px 38px rgba(35,31,25,.17)}.al-footer-menu button{width:100%;padding:9px;border:0;border-radius:7px;background:transparent;color:var(--al-text);font-size:9px;text-align:left;cursor:pointer}.al-footer-menu button:hover{background:var(--al-brand-soft);color:var(--al-brand)}.al-detail{padding:12px}.al-detail-hero{grid-template-columns:148px minmax(0,1fr);gap:12px;padding:10px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-detail-main-photo{height:178px}.al-detail-summary h2{font-size:14px}.al-publish-flow{margin-top:11px;padding:12px;border:1px solid var(--al-line);border-radius:14px;background:var(--al-surface-strong)}.al-flow-grid{grid-template-columns:1fr!important}.al-autofill-card{margin-top:8px}.al-safe-actions{display:grid;gap:7px;padding:10px;border:1px solid rgba(35,133,91,.18);border-radius:12px;background:var(--al-success-soft)}.al-safe-actions p{color:var(--al-text-secondary);font-size:8px;line-height:1.45}.al-final-actions{margin:9px 0;padding:9px 10px;border:1px solid var(--al-line);border-radius:11px;background:var(--al-surface-strong)}.al-final-actions summary{color:var(--al-text-secondary);font-size:9px;font-weight:800;cursor:pointer}.al-final-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:9px}.al-setting-hint{display:block;margin-top:5px;color:var(--al-text-muted);font-size:8px;line-height:1.4}.al-empty{display:grid;justify-items:center;gap:4px;padding:44px 20px!important}.al-empty strong{font-size:12px}.al-empty small{color:var(--al-text-muted);font-size:9px} +@media(max-width:560px){#al-panel{top:8px;right:8px;left:8px;width:auto;height:calc(100vh - 16px);min-width:0;border-radius:16px}.al-detail-hero{grid-template-columns:1fr}.al-detail-main-photo{height:210px}.al-meta-row,.al-final-grid{grid-template-columns:1fr}.al-footer-row{flex-wrap:wrap}.al-footer-note{flex-basis:100%}} + +/* Slice 09 compact density / hard-edge refinement */ +:root { + --al-radius-lg: 5px !important; + --al-radius-md: 5px !important; + --al-radius-sm: 3px !important; +} +#al-panel{border-radius:5px!important;background:#f4f0e9!important;box-shadow:0 18px 44px rgba(35,31,25,.18)!important} +#al-panel .al-hd, +#al-panel .al-tabs, +#al-panel .al-batch-bar, +#al-panel .al-footer{background:#fffdf9!important} +#al-panel .al-hd{min-height:54px!important;padding:8px 10px 8px 12px!important} +#al-panel .al-hd button, +#al-panel .al-item-open, +#al-panel .al-tab, +#al-panel .al-btn, +#al-panel .al-select, +#al-panel .al-input, +#al-panel input, +#al-panel select, +#al-panel textarea{border-radius:5px!important} +#al-panel .al-hd-status, +#al-panel .al-item-status, +#al-panel .al-detail-ready, +#al-panel .al-queue-number, +#al-panel .al-autofill-card>div, +#al-panel .al-flow-grid li>b, +#al-panel .al-footer-menu, +#al-panel .al-footer-menu button, +#al-panel .al-tab, +#al-panel .al-queue-overview span, +#al-panel .al-batch-bar label{border-radius:0!important} +#al-panel .al-hd-status{padding:4px 7px!important;font-size:9px!important} +#al-panel .al-tabs{padding:0!important;gap:0!important;border-bottom:1px solid var(--al-line)!important} +#al-panel .al-tab{min-height:31px!important;border-right:1px solid var(--al-line)!important;background:#fffdf9!important;font-size:10px!important} +#al-panel .al-tab:last-child{border-right:0!important} +#al-panel .al-tab.active{background:#f7ebe6!important;color:var(--al-brand)!important;box-shadow:inset 0 -2px 0 var(--al-brand)!important} +#al-panel .al-batch-bar{min-height:34px!important;padding:6px 10px!important} +#al-panel .al-list{padding:6px 8px 10px!important} +#al-panel .al-queue-overview{margin:8px!important;padding:10px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-queue-number{min-width:44px!important;padding:6px!important;background:#f7ebe6!important} +#al-panel .al-item{grid-template-columns:18px 56px minmax(0,1fr) auto!important;grid-template-rows:auto auto!important;gap:6px 8px!important;min-height:0!important;padding:7px!important;margin-bottom:6px!important;border-radius:5px!important;box-shadow:none!important} +#al-panel .al-item-img,#al-panel .al-image-placeholder{width:56px!important;height:64px!important;border-radius:5px!important;padding:3px!important} +#al-panel .al-item-topline{margin-bottom:3px!important} +#al-panel .al-item-title{font-size:11px!important;line-height:1.25!important} +#al-panel .al-item-meta,#al-panel .al-item-sku{font-size:8px!important} +#al-panel .al-item-side strong{font-size:10px!important} +#al-panel .al-item-open{width:24px!important;height:24px!important;font-size:15px!important} +#al-panel .al-item-actions{grid-column:2/-1!important;grid-row:2!important;display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:5px!important} +#al-panel .al-quick-fill, +#al-panel .al-quick-detail{min-height:26px!important;padding:0 6px!important;border-radius:5px!important;font-size:9px!important} +#al-panel .al-quick-fill-all{background:#fffdf9!important;color:var(--al-text)!important;border:1px solid var(--al-line)!important} +#al-panel .al-quick-fill-all:hover{border-color:var(--al-brand)!important;color:var(--al-brand)!important} +#al-panel .al-item-status{padding:2px 5px!important;font-size:7px!important} +#al-panel .al-btn{min-height:30px!important;padding:0 8px!important;font-size:10px!important;font-weight:800!important;box-shadow:none!important} +#al-panel .al-footer{padding:8px 10px!important} +#al-panel .al-footer-row{display:flex!important;flex-wrap:wrap!important;gap:5px!important} +#al-panel .al-footer-row .al-btn{flex:1 1 calc(33.333% - 4px);min-width:96px} +#al-panel .al-footer-note{flex:1 0 100%!important;margin-bottom:2px!important} +#al-panel .al-footer-empty{justify-items:start!important;text-align:left!important;padding:0!important} +#al-panel .al-detail{padding:10px!important} +#al-panel .al-detail-back{margin-bottom:8px!important} +#al-panel .al-detail-hero,#al-panel .al-publish-flow,#al-panel .al-safe-actions,#al-panel .al-final-actions,#al-panel .al-edit-details{border-radius:5px!important} +#al-panel .al-detail-hero{grid-template-columns:132px minmax(0,1fr)!important;gap:10px!important;padding:8px!important} +#al-panel .al-detail-main-photo{height:160px!important;border-radius:5px!important;padding:8px!important} +#al-panel .al-detail-summary h2{font-size:14px!important} +#al-panel .al-detail-summary>strong{font-size:15px!important;margin-bottom:10px!important} +#al-panel .al-detail-photos{gap:5px!important} +#al-panel .al-detail-photo{border-radius:5px!important} +#al-panel .al-publish-flow{padding:10px!important} +#al-panel .al-flow-grid li strong{font-size:9px!important} +#al-panel .al-flow-grid li small{font-size:7px!important} +#al-panel .al-autofill-card{border-radius:5px!important;padding:8px!important} +#al-panel .al-edit-details summary, +#al-panel .al-final-actions summary{font-size:10px!important;padding:9px 10px!important} +#al-panel .al-field input,#al-panel .al-field textarea,#al-panel .al-field select{border-radius:5px!important} +#al-panel .al-fab{border-radius:5px!important} +@media(max-width:560px){#al-panel{border-radius:5px!important;top:8px!important;left:8px!important;right:8px!important;width:auto!important;height:calc(100vh - 16px)!important}#al-panel .al-detail-hero{grid-template-columns:1fr!important}#al-panel .al-footer-row .al-btn{flex:1 1 calc(50% - 4px)} } +.al-hd-brand-logo{width:31px;height:31px;display:block;object-fit:contain;flex:0 0 auto} +.al-fab img{width:34px;height:34px;display:block;object-fit:contain;margin:auto} diff --git a/extensions/safari/web-extension/content.js b/extensions/safari/web-extension/content.js new file mode 100644 index 0000000..a3c9c96 --- /dev/null +++ b/extensions/safari/web-extension/content.js @@ -0,0 +1,1494 @@ +(function() { + 'use strict'; + + const browserApi = globalThis.browser || null; + const chromeApi = globalThis.chrome || null; + const VENDOO_BRAND_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALe0lEQVR4nNWZe5BcVZ3HP+fce7unO895BYKhTGSKgKZSsuUqFokSWI3iRoMWRaIGtFJZICSKY8LWvtxdWEsBV1aI5dYuBpwQJIESK5PgM0MJRg1rWDc8dsMjkl1DSEgymelJ9/S995zf/nHu7e7b0xMnQbQ8Uz3dc+bcc77f3/t3WsVxLEop/tiGtRYTx/h/jOABtNbgeeg/NJCJD2n46D5rz8Of2LPJw2+EtkTIgKsN1XRew+eG+VMQEDAGtAbtu+fjGMSC52U3PNNhDfjBuMdjItBeZqr51NYEREBpCJKHq6OICKqtkBxskzWvg4SIAz9ahuFBh8xat6e10N4N+QKY2GFpAb41AREnYRPBIxth4GE4vN9pdNb5yHuvgiWfcpJp2Pz0wFvwfNh8O2z9BlQG3ZxKBGctTJ0B1/0DvH+F07xufY4yxkhmY+2hjr0Kf3s1/OIJyAGpxYRADLLgMrj1QZje5czgdEiYGIIcfP9+1LoVMDnZXwDfS7Qg7ixPId98Ei58B8RZcxpLQCSx8whWX4p6cjd0tzk/aHRireFoFVm4AP5lZyKZZocbbyT7hCFcOx91YD+0+SAGrEVOCiqHm1MenKwib38XfGOXw6AVzYZUF501KO3B9ntRu3dDRx6qoSNgbfIyEEXQmUf99KewY6MzBWsmKH3jpPj9Tah9L0Lec3ORQYI86vIr4C1zoRyDjaEYoP5jN3xnA/g+GDtmyzoB7SEi8OPN0JbYYasophxZChq15atQrTgSLUNho/DFrauU4f7bIKfcnBWkMBXuegy5Ywdyzy+Rd78fytYdNslD/fsX4JVfOxI2S0LXN/egNAQHXgDfAg0LVdO7WGjz4H9eQO3c4szI/BYt2CQkP3ofPL8fCr4zu7KFP10E8y6Gk8NQnIxctQaJk/yQ8+C1Ydj2b269tCKQSs/EKKSuF0XWtqXh3YqT4gO3QxQ66co4WqhJ/yRsvgNVSICIQAAc3O/+njTVHfv8f9aVbxMgpRMtt04CrHYSmtYBZ82CSNWdE0kAJ8BrJKxztuf+GzXwkFs/ni+k0t9xL+qFlxPHFbdHwUM9/zT81ZXwxDbkm7egvvVlKGpn82LBF1j4kUSoWSeuR6E0vG28FfW1L0BHzs3VpJA+UeeF0lAxcOE82LgHUXpsNEoT3mgZPjkPdfB/nfMi2ehWNs46LKgi4OkkEkWw4DLkaztb5p2MEyMCV16PvGkGhEnyUMqtGhvBnHSKHjzzNDz+3SQBxuNIfyPqpQNO+jV1NpCcHKCmBqjpQVKqAFogp5GV/1hflwXQQEAplwPau+Fjq+GkdWzHs+v6gy4RPXAbyppsskltPxyFhzdAPok8rbY0JgnZxpHzfRiJ4dI/h/kLkmzcnMhUUzmtk/D50dXIOV1QjR3AscTrw1oo+vCrPfCzRxMtmDoBrZHf7Ee9sh/aVFYgitaaBUekzUc+9fdknS87sgSUdiaQaqFi6zXIeAc11ogP3OYI6SZH8zR4aqw2awRUdn/Ph5KB910Fc/9kHOm3IgAOsFj46I0wsxNCU3fM9PxmItbAJB+e3IX65U4HwMRJ3BbkrDdjZ8wGQ4OTK5D01UhMuf0m55Fr/+63mnATgaQajGNon4FceYNLNKlTjacFlfxSwOYv1Uwn9SvVVkB96NMwIpALkvXS8FL1j4n05UMrYPaFThA6rfZaHJ2pRms8XFXK4BH4+IWo4RMuIzam8cbuoqYZhZQN6p6fIG9/j6ubtHb4TpZQ112Ceu45mB6M1So406tUES+ALfvgnNn1pqplO9PKhCDRQgQdZ8GHr4NKItFTdH/pdkpANn+5DkgpsIKaMh3+eQfy7kVQiWAohBNh/X04hKEqeAW4+evwpjkN4DOHZI8eo4GUaNobDB5Bls9FjZTAT/yjyWQzklQKqhbZ+HN427vqdXzaxAA89RPYtweiUci1gZ9D5fJIUIB574Rzz59ws9TahNJhDBIEqK+vR93zFZgWNJQLaTpuIqE9GI6QxR+BL303203ZJLd44wMTgChCea2jzgQINNiaTRz46CHnC+WRJERKg+NK9tGUkVHIxt0w96KxYdCaTHSx1iIiaO2hfd/180ku0VpzqrurFqJoWKyTiNR9DixZ6bJzKhmhnlVrvpE863lQiWHzbbS0Xe2B5yPawyqNn28jaCvg5XKEUQRAEAQEQYBSCtvUA2TQntKEoK6Fw/8Hy9+KiirOBNJNm32gNqfAaOjbg5w3b4wWRAQvCbV7nnqK7f39PPvsswwNDTNpUpHZs2dz+eV/xhVXfBClFHEcu9u40yYALhoEAeqra2HTBmjPOUAp2MY+IZ3TvossH1uB/E1fpil35qKpVCrccsutPPTwQ4RhSC7I4fkeYoUwDFEKLr74Yr74T1/kvJ7zGkjUD5oYgVQLh16GT8xDxaPOF9KLgEbbb0rXgoe6fy+SRBZJIku1WmXVqlUMDDxGd3c3SivX0iaPa63wPI/jxwfp6upi06Y+LrjggjGamNh9iE5qpHPmwBUrYMTWzaGRfqbSFBeGR0Lk23fUEpeI4Hket99+BwMDA5x99tkYazCxwRpbc2hjLEePHqNYLHD02FF6e3spl8torR3R0yKQSlQElq2D6UWITU3KLZOb4KJNUcP3NqMOvoT1fXzfZ9++fWzZ8iCdnV1EUYSuxXtJjhHKlTJLlixh1qxzKRaK/NfevWzduhWtdS1CnR6BVAvn9sDi5TCS1P6tMnwaNKzrB9TQKPLgV5BkYX9/P6XSCJ7nYYyhXC7XqlKFYmh4iPXr1rFhw9309n6OkVKJScUi27b1Y63FSxPiaREAUi3I8vUwtS3bfbXKzrUrGAUDW9DHDgPw9N69BEFAbGLaCm3Mnz+f0cooCjg+eJy/vPlmrr/+ekqlEn19fQS5HEEQcODAyxw+fBjP00gSBU+PQKqFN89FPrDc+YLnjQWfqVrFXRIfGUS99CsEODE4SC6XY6RU4i9WreKRR77D6tU38Oqrh1m/bh1r1qyhXC5z44038vjjj1MsFgGoVCoMDw9n5HUGN7OJLyy/Gabkx94HCWMbe61RRiGvvYIC2vJ5jDXkcnl2795NGIb09vaydcsWbrrpJsIwZM3atQwMPEZHRyfGGncDEwTk8/ns1qeNP9XC7AuQxcsSLTRccivGto3J1Yid2g5AT08PYTVk8uTJ7Nw5wNq1awnDkEWXLSKOYz7z2c/ywx/8kK6uTmITo5UmiiJmzJjBzJkz3VV/IqQz/4pJBJath0JQbyNb9bhKgbHIlAL0XATA+xYvrpUIHR0d7NjxKGvWrOWZZ56h9/Ofp39bP52dncRJstSeS3oLF76HfD5PHMc1AhNLZK1G+u3KrdegHt4E3QV3QweJgSY5IZeH1yrIsk/DX29EohBRmmuuuZZdu3bR3t6OMYZKpYzn+8SxoVgs1OofT2uiKCaXC9i+fTuzZs3CGFNLZmdOQJJvU4aOo25YCPv2uRKjRiAZgyHMPR/51ydgWifWGPwgx4svvsjVVy9jcHCQadOmYqzFGotSSUZWCt/zCMOQUqnE3XffxdKlS4miCK+h1D5zE0rvjNq7kbt/jFz6QTjZ0F2dCKEUIu9djNz1I2ifASJoPyCOY3p6erjvvnuZM2cOR468RqVccVk4+YnCiGPHjqG15s4772Tp0qXEcZwBD69HA+lo7LT2POZeg0ehowsuWgTvWOT+19RhWWvxfZ/h4WH6+vro79/OoUOHGK2OEvgBnZ0dXHLJAlauXElPppBrkuPrJgD1qNOqizLJTVuL9jAlARBFEQd/c5ATQycoFovMnDmTKVOmAIwL/ndHoIbINIVQ5YCfoqMSkRqR5s4riiKUUuOCh981gdcxRCRTZSqlTtlKpmNi39T/HsZEAdfWk9whvGGI3uAhQFitoqMw/ENjOaMRVqsgwv8DCKxuUCexcEoAAAAASUVORK5CYII='; + + 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 ``; + return `${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 = ` +
                                                                  + + + ... + + + + +
                                                                  +
                                                                  + + + +
                                                                  +
                                                                  + + + 0 ausgewählt +
                                                                  +
                                                                  +
                                                                  +
                                                                  +
                                                                  Vinted WorkflowBereit zum AusfüllenWähle einen Artikel und übertrage die Daten kontrolliert.
                                                                  +
                                                                  0offen
                                                                  +
                                                                  +
                                                                    +
                                                                    +
                                                                    +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      Batch-Modus
                                                                      + + „Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts. +
                                                                      +
                                                                      +
                                                                      Pause zwischen Artikeln (Sek.)
                                                                      + +
                                                                      +
                                                                      +
                                                                      Server URL
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + `; + 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 = ``; 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 = '
                                                                    • Warteschlange erledigtNeue Vinted-Listings erscheinen automatisch hier.
                                                                    • '; + 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 ? 'Läuft' : 'Bereit'; + return ` +
                                                                    • +
                                                                      + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                                      +
                                                                      ${statusHtml}Vinted
                                                                      +
                                                                      ${esc(l.title || 'Ohne Titel')}
                                                                      +
                                                                      ${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}
                                                                      +
                                                                      ${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}
                                                                      +
                                                                      +
                                                                      ${l.price ? `${Number(l.price).toFixed(2).replace('.', ',')} €` : 'Preis fehlt'}
                                                                      +
                                                                      + + + +
                                                                      +
                                                                    • `; + }).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 = '
                                                                    • 📦Noch keine Artikel veröffentlicht.
                                                                    • '; + return; + } + dl.innerHTML = STATE.published.map(l => { + const photo = l.photos?.[0] || ''; + return ` +
                                                                    • + ${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')} +
                                                                      +
                                                                      ${esc(l.title || 'Ohne Titel')}
                                                                      +
                                                                      ${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}
                                                                      +
                                                                      + Erledigt +
                                                                    • `; + }).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 = ''; + actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; }); + return; + } + if (count === 1) { + const id = [...STATE.selected][0]; + actions.innerHTML = ` + + + + + `; + 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 = ` + + + `; + 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 = ''; + } + } + + // --- 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 = ` +
                                                                      + + +
                                                                      + ${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)} +
                                                                      + Bereit +

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

                                                                      + ${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'} +
                                                                      Kategorie
                                                                      ${esc(listing.category || 'Nicht gesetzt')}
                                                                      Marke
                                                                      ${esc(listing.brand || '–')}
                                                                      Größe
                                                                      ${esc(listing.size || '–')}
                                                                      Zustand
                                                                      ${esc(listing.condition || '–')}
                                                                      +
                                                                      +
                                                                      +
                                                                      ${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}
                                                                      + +
                                                                      +

                                                                      Veröffentlichen auf Vinted

                                                                      +
                                                                      +
                                                                      1. 1Daten prüfenTitel, Preis, Kategorie, Größe
                                                                      2. 2Bilder vorbereiten${photos.length}/${photos.length || 0} Bilder verfügbar
                                                                      3. 3Beschreibung übernehmenBeschreibung und Attribute
                                                                      4. 4Auto-Fill auf VintedFormular automatisch ausfüllen
                                                                      5. 5VeröffentlichungListing abschließen
                                                                      +
                                                                      Auto-Fill · Vinted

                                                                      Vendoo übernimmt alle vorhandenen Listing-Daten.

                                                                      • ✓ Titel
                                                                      • ✓ Preis
                                                                      • ✓ Kategorie
                                                                      • ✓ Größe & Marke
                                                                      • ✓ Beschreibung
                                                                      • ✓ Bilder
                                                                      ✓ Bereit zum ÜbertragenAlle Pflichtdaten werden vorab geprüft.
                                                                      +
                                                                      +
                                                                      + +
                                                                      Listing-Daten prüfen & bearbeiten +
                                                                      +
                                                                      Titel
                                                                      + +
                                                                      +
                                                                      +
                                                                      Beschreibung
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      Preis
                                                                      + +
                                                                      +
                                                                      +
                                                                      Zustand
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      Marke
                                                                      + +
                                                                      +
                                                                      +
                                                                      Größe
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      Kategorie (Vinted)
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      Farbe
                                                                      + +
                                                                      +
                                                                      +
                                                                      AI-Kategorie (Original)
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      Tags
                                                                      + +
                                                                      + +
                                                                      + + +
                                                                      + + +

                                                                      Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.

                                                                      +
                                                                      + +
                                                                      + Entwurf speichern oder veröffentlichen +
                                                                      + + + + +
                                                                      +
                                                                      + +
                                                                      + `; + + 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 = '
                                                                      Keine Kategorie gefunden
                                                                      '; + return; + } + dropdown.innerHTML = matches.map((m, i) => ` +
                                                                      +
                                                                      ${esc(m.name)}
                                                                      +
                                                                      ${esc(m.path.slice(0, -1).join(' > '))}
                                                                      +
                                                                      + `).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 = '
                                                                      '; + for (const main of VINTED_CATEGORIES) { + html += `
                                                                      `; + html += `
                                                                      ${esc(main.name)}
                                                                      `; + html += `
                                                                      `; + if (main.children) { + for (const sub of main.children) { + html += `
                                                                      `; + html += `
                                                                      ${esc(sub.name)}
                                                                      `; + html += `
                                                                      `; + if (sub.children) { + for (const leaf of sub.children) { + html += `
                                                                      ${esc(leaf.name)}
                                                                      `; + } + } + html += '
                                                                      '; + } + } + html += '
                                                                      '; + } + html += '
                                                                      '; + 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 = ` +
                                                                      +
                                                                      +
                                                                      ${esc(cat.name)}
                                                                      +
                                                                      ${esc(cat.path ? cat.path.join(' > ') : cat.name)}
                                                                      +
                                                                      + +
                                                                      + `; + 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 = ` +
                                                                      +
                                                                      + Modus: ${modeText} +
                                                                      +
                                                                      +
                                                                      +
                                                                      + 0 / ${STATE.batchProgress.total} + 0% +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + `; + 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 += `
                                                                      • ${esc(title.substring(0, 30))}... ${step}
                                                                      `; + 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 += `
                                                                      ${icon} ${esc(title)}${msg ? ' — ' + esc(msg) : ''}
                                                                      `; + 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(); +})(); + diff --git a/extensions/safari/web-extension/icon-128.png b/extensions/safari/web-extension/icon-128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/extensions/safari/web-extension/icon-128.png differ diff --git a/extensions/safari/web-extension/icon-16.png b/extensions/safari/web-extension/icon-16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/extensions/safari/web-extension/icon-16.png differ diff --git a/extensions/safari/web-extension/icon-32.png b/extensions/safari/web-extension/icon-32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/extensions/safari/web-extension/icon-32.png differ diff --git a/extensions/safari/web-extension/icon-48.png b/extensions/safari/web-extension/icon-48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/extensions/safari/web-extension/icon-48.png differ diff --git a/extensions/safari/web-extension/icon.svg b/extensions/safari/web-extension/icon.svg new file mode 100644 index 0000000..d979d2f --- /dev/null +++ b/extensions/safari/web-extension/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/safari/web-extension/manifest.json b/extensions/safari/web-extension/manifest.json new file mode 100644 index 0000000..bb47c7c --- /dev/null +++ b/extensions/safari/web-extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Vendoo Link – Safari Assistant", + "version": "1.9.0", + "description": "Vendoo Link als Safari Web Extension mit Vinted-Auto-Fill, Bildtransfer und Publishing.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "http://localhost:8124/*", + "http://127.0.0.1:8124/*", + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://www.vinted.de/*", + "https://www.vinted.fr/*", + "https://www.vinted.co.uk/*", + "https://www.vinted.com/*" + ], + "js": [ + "vinted-categories.js", + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Vendoo Assistant", + "default_icon": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} diff --git a/extensions/safari/web-extension/popup.css b/extensions/safari/web-extension/popup.css new file mode 100644 index 0000000..3247114 --- /dev/null +++ b/extensions/safari/web-extension/popup.css @@ -0,0 +1,36 @@ +:root{ + --bg:#f4f0e9; + --surface:#fffdf9; + --surface-2:#f8f5ef; + --line:#ddd6cb; + --ink:#22201d; + --muted:#736d64; + --brand:#e63b17; + --brand-dark:#c92f0d; + --success:#218356; + --danger:#bd4339; + --radius:5px; +} +*{box-sizing:border-box} +body{width:420px;min-height:600px;margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input,select{font:inherit} +.popup-shell{min-height:600px;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg)} +.popup-header{display:flex;align-items:center;gap:8px;padding:12px 12px 10px;border-bottom:1px solid var(--line);background:var(--surface)} +.brand-lockup{display:flex;align-items:center;gap:8px;min-width:0}.brand-lockup img{width:28px;height:28px}.brand-lockup>div{display:grid;gap:1px}.brand-lockup strong{font-size:14px;letter-spacing:-.03em}.brand-lockup span{font-size:10px;color:var(--muted)} +.connection-chip{margin-left:auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--line);background:#efebe4;color:var(--muted);font-size:10px;font-weight:700}.connection-chip i{width:7px;height:7px;background:#999}.connection-chip.ok{background:#edf7f2;color:var(--success);border-color:#b9dec8}.connection-chip.ok i{background:var(--success)}.connection-chip.error{background:#fbeceb;color:var(--danger);border-color:#eac0bc}.connection-chip.error i{background:var(--danger)} +.icon-button,.back-button{width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--muted);cursor:pointer}.icon-button:hover,.back-button:hover{background:var(--surface-2);color:var(--ink)} +.popup-content,.settings-view{padding:12px;overflow:auto} +.summary-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.summary-main{display:grid;gap:2px}.eyebrow{color:var(--brand);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.summary-main strong{font-size:13px}.summary-main small{color:var(--muted);font-size:10px;line-height:1.4}.summary-side{min-width:52px;padding:8px 7px;border:1px solid var(--line);background:var(--surface-2);text-align:center}.summary-side b{display:block;font-size:18px;color:var(--brand);line-height:1}.summary-side small{display:block;margin-top:4px;font-size:9px;color:var(--muted)} +.toolbar-row{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.tool-button{min-height:30px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px;font-weight:700;cursor:pointer}.tool-button:hover{border-color:var(--brand);color:var(--brand)} +.list-section{margin-top:10px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.section-head strong{font-size:12px}.section-head span{font-size:10px;color:var(--muted)} +.queue-list{display:grid;gap:7px} +.queue-item{display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:8px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)} +.queue-item-image{width:56px;height:64px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#ece8e0);display:grid;place-items:center;overflow:hidden}.queue-item-image img{width:100%;height:100%;object-fit:contain;padding:3px} +.queue-item-main{min-width:0;display:grid;gap:5px}.queue-item-top{display:flex;align-items:start;justify-content:space-between;gap:8px}.queue-item-title{font-size:12px;font-weight:750;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.queue-item-price{white-space:nowrap;font-size:11px;font-weight:800}.queue-item-meta{font-size:10px;color:var(--muted);line-height:1.35}.queue-item-sku{font-size:9px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace} +.queue-item-actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.item-action{min-height:26px;padding:0 6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:10px;font-weight:700;cursor:pointer}.item-action:hover{border-color:var(--brand);color:var(--brand)}.item-action.primary{border-color:var(--brand);background:var(--brand);color:#fff}.item-action.primary:hover{background:var(--brand-dark)} +.empty-card{padding:28px 18px;text-align:center;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.empty-card strong{font-size:14px}.empty-card p{color:var(--muted);font-size:11px;line-height:1.5}.empty-card button{border:0;background:transparent;color:var(--brand);font-size:11px;font-weight:800;cursor:pointer} +.settings-view{display:grid;align-content:start;gap:10px}.settings-heading{display:flex;align-items:center;gap:8px}.settings-heading>div{display:grid}.settings-heading strong{font-size:14px}.settings-heading span{font-size:10px;color:var(--muted)}.setting{display:grid;gap:5px}.setting label{color:var(--muted);font-size:10px;font-weight:700}.setting input,.setting select{width:100%;height:34px;padding:0 9px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);color:var(--ink);font-size:11px}.setting-row{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.primary-action{min-height:34px;border:1px solid var(--brand);border-radius:var(--radius);background:var(--brand);color:#fff;font-size:11px;font-weight:800;cursor:pointer}.primary-action:hover{background:var(--brand-dark)} +footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:10px} +.hidden{display:none!important} diff --git a/extensions/safari/web-extension/popup.html b/extensions/safari/web-extension/popup.html new file mode 100644 index 0000000..47d7a84 --- /dev/null +++ b/extensions/safari/web-extension/popup.html @@ -0,0 +1,96 @@ + + + + + + Vendoo Link + + + +
                                                                      + + + + + + +
                                                                      + Vendoo Link v1.9.0 + Nur ausfüllen = kein Entwurf +
                                                                      +
                                                                      + + + diff --git a/extensions/safari/web-extension/popup.js b/extensions/safari/web-extension/popup.js new file mode 100644 index 0000000..540161c --- /dev/null +++ b/extensions/safari/web-extension/popup.js @@ -0,0 +1,204 @@ +const browserApi = globalThis.browser || null; +const chromeApi = globalThis.chrome || null; +const $ = selector => document.querySelector(selector); +const state = { queue: [], serverUrl: 'http://localhost:8124' }; + +function sendMessage(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) { + if (browserApi) return browserApi.storage.local.get(keys); + return new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); +} +function storageSet(values) { + if (browserApi) return browserApi.storage.local.set(values); + return new Promise(resolve => chromeApi.storage.local.set(values, resolve)); +} +function openTab(url) { + if (browserApi) return browserApi.tabs.create({ url }); + return chromeApi.tabs.create({ url }); +} +async function ensureServerPermission(url) { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.host}/*`; + const permissions = browserApi?.permissions || chromeApi?.permissions; + if (!permissions) return true; + if (browserApi) { + if (await permissions.contains({ origins: [origin] })) return true; + return permissions.request({ origins: [origin] }); + } + return new Promise(resolve => permissions.contains({ origins: [origin] }, has => has ? resolve(true) : permissions.request({ origins: [origin] }, resolve))); +} + +async function api(path, method = 'GET', body) { + const response = await sendMessage({ type: 'api', serverUrl: state.serverUrl, path, method, body }); + if (!response?.ok) throw new Error(response?.data?.error || response?.error || 'Server nicht erreichbar'); + return response.data; +} + +function setConnection(ok, text) { + const chip = $('#connection-chip'); + chip.className = `connection-chip ${ok ? 'ok' : 'error'}`; + chip.querySelector('span').textContent = text; +} + +async function imageData(filename) { + if (!filename) return ''; + const url = `${state.serverUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`; + const response = await sendMessage({ type: 'fetch-image', url }); + return response?.ok ? response.dataUrl : ''; +} + +function formatPrice(value) { + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2).replace('.', ',')} €` : 'Preis fehlt'; +} + +async function renderQueue() { + const list = $('#queue-list'); + const empty = $('#empty-card'); + $('#list-count').textContent = `${state.queue.length} Einträge`; + if (!state.queue.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = state.queue.map(item => ` +
                                                                      +
                                                                      ${escapeHtml(item.title || 'Produktbild')}
                                                                      +
                                                                      +
                                                                      +
                                                                      ${escapeHtml(item.title || 'Ohne Titel')}
                                                                      +
                                                                      ${formatPrice(item.price)}
                                                                      +
                                                                      +
                                                                      ${escapeHtml([item.brand, item.size, item.condition].filter(Boolean).join(' · ') || 'Noch keine Eigenschaften')}
                                                                      +
                                                                      ${escapeHtml(item.sku || `VD-${String(item.id).padStart(4, '0')}`)}
                                                                      +
                                                                      + + + + +
                                                                      +
                                                                      +
                                                                      + `).join(''); + + const imgNodes = [...list.querySelectorAll('img[data-photo]')]; + for (const img of imgNodes) { + const photo = img.dataset.photo; + if (!photo) { img.removeAttribute('src'); continue; } + const src = await imageData(photo); + if (src) img.src = src; + } + + list.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async event => { + const { action, id } = event.currentTarget.dataset; + const listing = state.queue.find(item => item.id === Number(id)); + if (!listing) return; + if (action === 'fill') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: false }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'fill-all') { + await storageSet({ pendingFillListingId: listing.id, pendingFillIncludePhotos: true }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'detail') { + await storageSet({ pendingOpenListingId: listing.id }); + openTab('https://www.vinted.de/items/new'); + window.close(); + return; + } + if (action === 'app') { + openTab(`${state.serverUrl}/#publish`); + window.close(); + } + })); +} + +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() { + 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: 'popup', + server_url: state.serverUrl, + capabilities: { popup: true, queue: true, publishing_queue: true, diagnostics: true }, + }); + } catch {} +} + +async function loadQueue() { + try { + const data = await api('/api/extension/queue?platform=vinted'); + state.queue = data.queue || []; + setConnection(true, 'Verbunden'); + await sendDiagnosticHeartbeat(); + $('#queue-state').textContent = state.queue.length ? 'Bereit zum Ausfüllen' : 'Queue leer'; + $('#queue-summary-text').textContent = state.queue.length + ? 'Sichere Autofill-Aktionen ohne versteckte Veröffentlichung.' + : 'Neue Vinted-Listings erscheinen automatisch hier.'; + $('#queue-count').textContent = String(state.queue.length); + await renderQueue(); + } catch (error) { + setConnection(false, 'Offline'); + $('#queue-state').textContent = 'Server nicht erreichbar'; + $('#queue-summary-text').textContent = error.message; + state.queue = []; + await renderQueue(); + } +} + +async function init() { + const settings = await storageGet(['serverUrl', 'provider', 'language']); + state.serverUrl = String(settings.serverUrl || 'http://localhost:8124').replace(/\/$/, ''); + $('#server-url').value = state.serverUrl; + if (settings.provider) $('#provider').value = settings.provider; + if (settings.language) $('#language').value = settings.language; + await loadQueue(); +} + +$('#settings-toggle').addEventListener('click', () => { $('#dashboard-view').classList.add('hidden'); $('#settings-view').classList.remove('hidden'); }); +$('#back-dashboard').addEventListener('click', () => { $('#settings-view').classList.add('hidden'); $('#dashboard-view').classList.remove('hidden'); }); +$('#save-settings').addEventListener('click', async () => { + state.serverUrl = $('#server-url').value.trim().replace(/\/$/, ''); + if (!await ensureServerPermission(state.serverUrl)) throw new Error('Zugriff auf die Serveradresse wurde nicht erlaubt.'); + await storageSet({ serverUrl: state.serverUrl, provider: $('#provider').value, language: $('#language').value }); + $('#settings-view').classList.add('hidden'); + $('#dashboard-view').classList.remove('hidden'); + await loadQueue(); +}); +$('#reload-list').addEventListener('click', loadQueue); +$('#open-queue').addEventListener('click', () => openTab(`${state.serverUrl}/#publish`)); +$('#open-app').addEventListener('click', () => openTab(state.serverUrl)); +$('#empty-open-app').addEventListener('click', () => openTab(state.serverUrl)); + +function escapeHtml(input) { + return String(input ?? '').replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} + +setInterval(sendDiagnosticHeartbeat, 60 * 1000); +init(); diff --git a/extensions/safari/web-extension/vinted-categories.js b/extensions/safari/web-extension/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extensions/safari/web-extension/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/extensions/vinted-categories.js b/extensions/vinted-categories.js new file mode 100644 index 0000000..1b06b84 --- /dev/null +++ b/extensions/vinted-categories.js @@ -0,0 +1,337 @@ +/* eslint-disable no-var */ +var VINTED_CATEGORIES = [ + { + id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, + { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, + { id: 4, name: 'Kleider' }, + { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, + { id: 8, name: 'Jeans' }, + { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, + { id: 11, name: 'Sportbekleidung' }, + { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, + { id: 14, name: 'Umstandsmode' }, + { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 16, name: 'Kostüme & Verkleidungen' }, + { id: 1226, name: 'Shorts' }, + { id: 1904, name: 'Sonstige Kleidung' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, + { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, + { id: 20, name: 'Sandalen' }, + { id: 21, name: 'Ballerinas' }, + { id: 22, name: 'Schnürschuhe & Slipper' }, + { id: 23, name: 'Sportschuhe' }, + { id: 24, name: 'Hausschuhe' }, + { id: 1905, name: 'Sonstige Schuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, + { id: 26, name: 'Umhängetaschen' }, + { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches & Abendtaschen' }, + { id: 29, name: 'Geldbörsen & Etuis' }, + { id: 30, name: 'Shopper & Tragetaschen' }, + { id: 1906, name: 'Sonstige Taschen' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, + { id: 32, name: 'Uhren' }, + { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, + { id: 35, name: 'Gürtel' }, + { id: 36, name: 'Mützen & Hüte' }, + { id: 37, name: 'Handschuhe' }, + { id: 1907, name: 'Sonstige Accessoires' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, + { id: 39, name: 'Hautpflege' }, + { id: 40, name: 'Haarpflege' }, + { id: 41, name: 'Parfum' }, + { id: 42, name: 'Nagelpflege' }, + { id: 1919, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, + { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, + { id: 53, name: 'Hosen' }, + { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, + { id: 56, name: 'Jacken & Mäntel' }, + { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, + { id: 59, name: 'Bademode' }, + { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + { id: 61, name: 'Kostüme & Verkleidungen' }, + { id: 1908, name: 'Sonstige Kleidung' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, + { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, + { id: 65, name: 'Sandalen' }, + { id: 66, name: 'Sportschuhe' }, + { id: 67, name: 'Hausschuhe' }, + { id: 1909, name: 'Sonstige Schuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, + { id: 69, name: 'Umhängetaschen' }, + { id: 70, name: 'Aktentaschen' }, + { id: 71, name: 'Geldbörsen & Etuis' }, + { id: 1910, name: 'Sonstige Taschen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, + { id: 73, name: 'Sonnenbrillen' }, + { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, + { id: 76, name: 'Mützen & Hüte' }, + { id: 77, name: 'Schals' }, + { id: 78, name: 'Schmuck' }, + { id: 1911, name: 'Sonstige Accessoires' }, + ]}, + { id: 1920, name: 'Pflege', children: [ + { id: 79, name: 'Rasur & Bartpflege' }, + { id: 80, name: 'Hautpflege' }, + { id: 81, name: 'Parfum' }, + { id: 1920, name: 'Sonstige Pflege' }, + ]}, + ] + }, + { + id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, + { id: 101, name: 'Pullover & Strickjacken' }, + { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, + { id: 104, name: 'Jacken & Mäntel' }, + { id: 1912, name: 'Sonstige Mädchen-Kleidung' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, + { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, + { id: 113, name: 'Jacken & Mäntel' }, + { id: 1913, name: 'Sonstige Jungen-Kleidung' }, + ]}, + { id: 1914, name: 'Baby-Kleidung', children: [ + { id: 120, name: 'Strampler & Bodies' }, + { id: 121, name: 'Sets & Outfits' }, + { id: 122, name: 'Jacken' }, + { id: 1914, name: 'Sonstige Baby-Kleidung' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, + { id: 131, name: 'Stiefel' }, + { id: 132, name: 'Sandalen' }, + { id: 133, name: 'Sportschuhe' }, + { id: 1915, name: 'Sonstige Kinderschuhe' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen & Zubehör' }, + { id: 141, name: 'Fahrzeuge' }, + { id: 142, name: 'Bausteine & Baukästen' }, + { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + { id: 145, name: 'Lernspielzeug' }, + { id: 1916, name: 'Sonstiges Spielzeug' }, + ]}, + ] + }, + { + id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, + { id: 201, name: 'Kerzen & Windlichter' }, + { id: 202, name: 'Vasen' }, + { id: 203, name: 'Spiegel' }, + { id: 204, name: 'Figuren & Skulpturen' }, + { id: 1917, name: 'Sonstige Dekoration' }, + ]}, + { id: 245, name: 'Küche & Essen', children: [ + { id: 210, name: 'Geschirr' }, + { id: 211, name: 'Gläser & Tassen' }, + { id: 212, name: 'Besteck' }, + { id: 213, name: 'Küchengeräte' }, + { id: 214, name: 'Aufbewahrung' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, + { id: 221, name: 'Handtücher' }, + { id: 222, name: 'Kissen & Decken' }, + { id: 223, name: 'Teppiche' }, + { id: 224, name: 'Vorhänge' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, + { id: 231, name: 'Tische' }, + { id: 232, name: 'Stühle' }, + { id: 233, name: 'Schränke & Kommoden' }, + { id: 234, name: 'Betten' }, + ]}, + { id: 248, name: 'Garten', children: [ + { id: 240, name: 'Gartenmöbel' }, + { id: 241, name: 'Pflanzen & Töpfe' }, + { id: 242, name: 'Grill & Outdoor' }, + { id: 243, name: 'Gartengeräte' }, + ]}, + ] + }, + { + id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane & Erzählungen' }, + { id: 301, name: 'Sachbücher' }, + { id: 302, name: 'Kinder- & Jugendbücher' }, + { id: 303, name: 'Comics & Manga' }, + { id: 304, name: 'Fach- & Schulbücher' }, + ]}, + { id: 250, name: 'Musik & Film', children: [ + { id: 310, name: 'CDs' }, + { id: 311, name: 'Vinyl & Schallplatten' }, + { id: 312, name: 'DVDs & Blu-ray' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, + { id: 321, name: 'Konsolen' }, + { id: 322, name: 'Zubehör' }, + { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport & Freizeit', children: [ + { id: 330, name: 'Fitnessgeräte' }, + { id: 331, name: 'Fahrräder & Zubehör' }, + { id: 332, name: 'Camping & Wandern' }, + { id: 333, name: 'Wintersport' }, + { id: 334, name: 'Ballsport' }, + ]}, + ] + }, + { + id: 1210, name: 'Elektronik & Technik', children: [ + { id: 253, name: 'Handys & Smartphones', children: [ + { id: 400, name: 'iPhones' }, + { id: 401, name: 'Samsung' }, + { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & -zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, + { id: 411, name: 'Tablets' }, + { id: 412, name: 'PCs & Desktops' }, + { id: 413, name: 'Monitore' }, + { id: 414, name: 'Netzwerkspeicher (NAS)' }, + { id: 415, name: 'Server' }, + { id: 416, name: 'Drucker & Scanner' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, + { id: 421, name: 'Objektive' }, + { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, + { id: 431, name: 'Lautsprecher' }, + { id: 432, name: 'HiFi-Anlagen' }, + ]}, + { id: 257, name: 'TV & Video', children: [ + { id: 440, name: 'Fernseher' }, + { id: 441, name: 'Streaming-Geräte' }, + { id: 442, name: 'Beamer' }, + ]}, + { id: 258, name: 'Kabel & Zubehör', children: [ + { id: 450, name: 'Kabel' }, + { id: 451, name: 'Adapter' }, + { id: 452, name: 'Speicherkarten & USB-Sticks' }, + { id: 453, name: 'Ladegeräte & Akkus' }, + ]}, + ] + }, + { + id: 1211, name: 'Haustiere', children: [ + { id: 259, name: 'Hunde', children: [ + { id: 500, name: 'Hundeleine & Halsband' }, + { id: 501, name: 'Hundebett & Decke' }, + { id: 502, name: 'Hundespielzeug' }, + { id: 503, name: 'Hundebekleidung' }, + ]}, + { id: 260, name: 'Katzen', children: [ + { id: 510, name: 'Kratzbäume' }, + { id: 511, name: 'Katzenspielzeug' }, + { id: 512, name: 'Katzenbett' }, + ]}, + { id: 261, name: 'Sonstige Haustiere' }, + ] + }, +]; + +function flattenCategories(tree, path) { + const result = []; + path = path || []; + for (const cat of tree) { + const currentPath = [...path, cat.name]; + if (cat.children) { + for (const sub of cat.children) { + const subPath = [...currentPath, sub.name]; + if (sub.children) { + for (const leaf of sub.children) { + result.push({ + id: leaf.id, + name: leaf.name, + path: [...subPath, leaf.name], + pathStr: [...subPath, leaf.name].join(' > '), + search: [...subPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: sub.id, + name: sub.name, + path: subPath, + pathStr: subPath.join(' > '), + search: subPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +var VINTED_CATEGORIES_FLAT = flattenCategories(VINTED_CATEGORIES); + +function matchCategory(query) { + if (!query) return []; + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = VINTED_CATEGORIES_FLAT.map(cat => { + let score = 0; + const s = cat.search; + if (s.includes(q)) score += 100; + for (const w of words) { + if (w.length < 2) continue; + if (s.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + // Bonus for exact leaf match + if (cat.name.toLowerCase() === q) score += 200; + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} diff --git a/lib/ai-claude.mjs b/lib/ai-claude.mjs new file mode 100644 index 0000000..65c9401 --- /dev/null +++ b/lib/ai-claude.mjs @@ -0,0 +1,41 @@ +import Anthropic from '@anthropic-ai/sdk'; + +let client; + +function getClient() { + if (!client) client = new Anthropic(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image', + source: { type: 'base64', media_type: img.mediaType, data: img.base64 }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1500, + system: systemPrompt, + messages: [{ role: 'user', content }], + }); + + const text = response.content[0].text; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt) { + const response = await getClient().messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1400, + system: systemPrompt, + messages: [{ role: 'user', content: String(userPrompt || '') }], + }); + return response.content?.map(part => part.type === 'text' ? part.text : '').join('\n').trim(); +} diff --git a/lib/ai-flux-prompt-jobs.mjs b/lib/ai-flux-prompt-jobs.mjs new file mode 100644 index 0000000..25bbf6c --- /dev/null +++ b/lib/ai-flux-prompt-jobs.mjs @@ -0,0 +1,612 @@ +import { join } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { mkdirSync, writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; +import { + generateWithLocalFlux, + waitForLocalFluxPrompt, + cancelLocalFluxPrompt, +} from './ai-local-images.mjs'; +import { db, createFluxImage, getSettings } from './db.mjs'; + +const outputDir = join(UPLOADS_DIR, 'ai-generated'); +mkdirSync(outputDir, { recursive: true }); + +const activeVariants = new Map(); +let loopTimer = null; +let loopBusy = false; + +const STYLE_SUFFIXES = { + none: '', + photorealistic: 'photorealistic image, natural materials, physically plausible lighting, realistic proportions, fine surface detail, clean coherent composition', + product: 'professional commercial product photography, clear hero subject, controlled studio lighting, crisp edges, realistic materials, premium catalog composition', + editorial: 'premium editorial photography, refined magazine composition, intentional styling, controlled light, elegant color harmony, realistic detail', + cinematic: 'cinematic still, purposeful framing, atmospheric but realistic lighting, natural depth, controlled contrast, coherent visual storytelling', + illustration: 'polished high-quality digital illustration, coherent shapes, intentional line and color treatment, refined detail, professional composition', + social: 'modern social media visual, immediately readable focal point, clean hierarchy, polished lighting, balanced composition, premium finish', +}; + +const PROFILE_CONFIG = { + fast: { maxEdge: 768, steps: 4 }, + balanced: { maxEdge: 1024, steps: 4 }, + quality: { maxEdge: 1024, steps: 6 }, + custom: { maxEdge: 1536, steps: null }, +}; + +const MODE_SUFFIXES = { + raw: '', + precise: 'The main subject must be unambiguous and visually dominant. Keep all requested objects, positions, materials, colors, perspective and environment consistent.', + creative: 'Create a visually distinctive but coherent interpretation while preserving the requested subject, purpose and essential attributes.', +}; + +const TERMINAL_VARIANT_STATUSES = new Set(['completed', 'failed', 'cancelled']); +const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']); + +function normalizeWhitespace(value) { + return String(value || '').replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').trim(); +} + +function removeNegativePromptPatterns(value) { + return normalizeWhitespace(value) + .replace(/\bwithout\s+([\w-]+)/gi, 'avoiding visible $1') + .replace(/\bohne\s+([\wäöüß-]+)/gi, 'mit einer Gestaltung, in der $1 nicht im Fokus steht'); +} + +export function buildFluxPrompt(prompt, { style = 'none', mode = 'precise', assistant = true } = {}) { + const cleaned = removeNegativePromptPatterns(prompt); + if (!assistant) return [cleaned, STYLE_SUFFIXES[style] || ''].filter(Boolean).join('. '); + const sections = [cleaned]; + if (MODE_SUFFIXES[mode]) sections.push(MODE_SUFFIXES[mode]); + if (STYLE_SUFFIXES[style]) sections.push(STYLE_SUFFIXES[style]); + sections.push('single coherent scene, accurate anatomy and geometry where applicable, no unintended duplicate subjects, no watermark, no interface elements'); + return sections.map(normalizeWhitespace).filter(Boolean).join('. ').replace(/\.{2,}/g, '.'); +} + +function clampDimension(value, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(256, Math.min(1536, Math.round(number / 8) * 8)); +} + +function cleanStyle(value) { + const style = String(value || 'none').toLowerCase(); + return Object.hasOwn(STYLE_SUFFIXES, style) ? style : 'none'; +} + +function cleanProfile(value) { + const profile = String(value || 'balanced').toLowerCase(); + return Object.hasOwn(PROFILE_CONFIG, profile) ? profile : 'balanced'; +} + +function cleanMode(value) { + const mode = String(value || 'precise').toLowerCase(); + return Object.hasOwn(MODE_SUFFIXES, mode) ? mode : 'precise'; +} + +function cleanVariantCount(value) { + const count = Number(value); + return [1, 2, 4].includes(count) ? count : 1; +} + +function normalizeSeed(value) { + return Number.isFinite(Number(value)) + ? Math.max(0, Math.min(2147483647, Math.round(Number(value)))) + : Math.floor(Math.random() * 2147483647); +} + +function randomSeed(excluded = new Set()) { + let seed = Math.floor(Math.random() * 2147483647); + while (excluded.has(seed)) seed = Math.floor(Math.random() * 2147483647); + return seed; +} + +function buildSeeds(baseSeed, variants, seedLock, explicitSeeds = null) { + if (Array.isArray(explicitSeeds) && explicitSeeds.length === variants) { + return explicitSeeds.map(normalizeSeed); + } + const used = new Set(); + const seeds = []; + const base = normalizeSeed(baseSeed); + for (let index = 0; index < variants; index += 1) { + let seed; + if (seedLock) seed = (base + index) % 2147483648; + else seed = index === 0 ? base : randomSeed(used); + while (used.has(seed)) seed = (seed + 1) % 2147483648; + used.add(seed); + seeds.push(seed); + } + return seeds; +} + +function applyProfile({ profile, width, height, steps }) { + const cfg = PROFILE_CONFIG[profile]; + let outWidth = clampDimension(width, 768); + let outHeight = clampDimension(height, 768); + if (profile !== 'custom') { + const edge = Math.max(outWidth, outHeight); + if (edge > cfg.maxEdge) { + const scale = cfg.maxEdge / edge; + outWidth = Math.max(256, Math.round((outWidth * scale) / 8) * 8); + outHeight = Math.max(256, Math.round((outHeight * scale) / 8) * 8); + } + } + return { + width: outWidth, + height: outHeight, + steps: cfg.steps ?? Math.max(4, Math.min(12, Math.round(Number(steps) || 4))), + }; +} + +function parseDate(value) { + if (!value) return null; + const iso = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`; + const time = Date.parse(iso); + return Number.isFinite(time) ? time : null; +} + +function getVariantRows(jobId) { + return db.prepare(`SELECT v.*, i.image_path, i.favorite, i.prompt, i.final_prompt, i.width, i.height, i.steps, i.style, i.profile, i.prompt_mode + FROM flux_prompt_job_variants v + LEFT JOIN flux_images i ON i.id = v.image_id + WHERE v.job_id = ? ORDER BY v.variant_index`).all(String(jobId)); +} + +function publicVariant(row) { + return { + id: row.id, + job_id: row.job_id, + variant_index: Number(row.variant_index), + seed: Number(row.seed), + status: row.status, + phase: row.phase, + message: row.message || '', + prompt_id: row.prompt_id || null, + image_id: row.image_id || null, + image_path: row.image_path || null, + public_url: row.image_path ? `/uploads/${row.image_path}` : null, + favorite: Number(row.favorite || 0) === 1, + prompt: row.prompt || null, + final_prompt: row.final_prompt || null, + width: row.width ? Number(row.width) : null, + height: row.height ? Number(row.height) : null, + steps: row.steps ? Number(row.steps) : null, + style: row.style || null, + profile: row.profile || null, + prompt_mode: row.prompt_mode || null, + error: row.error_message || null, + duration_ms: row.duration_ms || null, + cancel_requested: Number(row.cancel_requested || 0) === 1, + created_at: row.created_at, + started_at: row.started_at, + completed_at: row.completed_at, + }; +} + +function publicJob(row, { includeVariants = true } = {}) { + if (!row) return null; + const variants = includeVariants ? getVariantRows(row.id).map(publicVariant) : undefined; + const createdMs = parseDate(row.created_at); + const startedMs = parseDate(row.started_at); + const completedMs = parseDate(row.completed_at); + return { + id: row.id, + prompt: row.prompt, + final_prompt: row.final_prompt, + width: Number(row.width), + height: Number(row.height), + steps: Number(row.steps), + style: row.style, + profile: row.profile, + prompt_mode: row.prompt_mode, + assistant: Number(row.assistant || 0) === 1, + variants_count: Number(row.variants), + seed_base: Number(row.seed_base), + seed_lock: Number(row.seed_lock || 0) === 1, + status: row.status, + phase: row.phase, + message: row.message || '', + error: row.error_message || null, + completed_variants: Number(row.completed_variants || 0), + source_job_id: row.source_job_id || null, + created_at: row.created_at, + started_at: row.started_at, + completed_at: row.completed_at, + archived_at: row.archived_at || null, + archived: Boolean(row.archived_at), + elapsed_ms: completedMs && startedMs ? completedMs - startedMs : startedMs ? Date.now() - startedMs : createdMs ? Date.now() - createdMs : null, + variants, + }; +} + +function fetchJobRow(id) { + return db.prepare('SELECT * FROM flux_prompt_jobs WHERE id = ?').get(String(id || '')); +} + +function updateParentJob(jobId) { + const job = fetchJobRow(jobId); + if (!job) return null; + const variants = db.prepare('SELECT status, error_message FROM flux_prompt_job_variants WHERE job_id = ? ORDER BY variant_index').all(jobId); + const counts = variants.reduce((acc, item) => { + acc[item.status] = (acc[item.status] || 0) + 1; + return acc; + }, {}); + const completed = counts.completed || 0; + const terminal = variants.length > 0 && variants.every(item => TERMINAL_VARIANT_STATUSES.has(item.status)); + let status = job.status; + let phase = job.phase; + let message = job.message; + let errorMessage = null; + let completedAt = null; + if (terminal) { + completedAt = new Date().toISOString(); + if (completed === variants.length) { + status = 'completed'; + phase = 'completed'; + message = `${completed} Bildvariante${completed === 1 ? '' : 'n'} fertig.`; + } else if ((counts.cancelled || 0) === variants.length) { + status = 'cancelled'; + phase = 'cancelled'; + message = 'Auftrag wurde abgebrochen.'; + } else if (completed > 0) { + status = 'completed_with_errors'; + phase = 'completed_with_errors'; + message = `${completed} von ${variants.length} Varianten fertig.`; + errorMessage = variants.find(item => item.error_message)?.error_message || null; + } else { + status = 'failed'; + phase = 'failed'; + message = 'Alle Bildvarianten sind fehlgeschlagen.'; + errorMessage = variants.find(item => item.error_message)?.error_message || null; + } + } else if ((counts.running || 0) > 0) { + status = job.status === 'cancelling' ? 'cancelling' : 'running'; + phase = job.status === 'cancelling' ? 'cancelling' : 'rendering'; + message = job.status === 'cancelling' ? 'Abbruch wird an ComfyUI übergeben …' : `${counts.running} Variante${counts.running === 1 ? '' : 'n'} läuft, ${counts.queued || 0} wartet.`; + } else if ((counts.queued || 0) > 0) { + status = job.status === 'cancelling' ? 'cancelling' : 'queued'; + phase = job.status === 'cancelling' ? 'cancelling' : 'queued'; + message = job.status === 'cancelling' ? 'Auftrag wird abgebrochen …' : `${counts.queued} Variante${counts.queued === 1 ? '' : 'n'} wartet.`; + } + db.prepare(`UPDATE flux_prompt_jobs SET status = ?, phase = ?, message = ?, error_message = ?, completed_variants = ?, + completed_at = COALESCE(?, completed_at), updated_at = datetime('now') WHERE id = ?`) + .run(status, phase, message, errorMessage, completed, completedAt, jobId); + return getFluxPromptJob(jobId); +} + +function saveResultImage(job, variant, result, startedAt) { + const relative = `ai-generated/${randomUUID()}.png`; + writeFileSync(join(UPLOADS_DIR, relative), Buffer.from(result.base64, 'base64')); + const durationMs = Math.max(0, Date.now() - startedAt); + const image = createFluxImage({ + prompt: job.prompt, + final_prompt: job.final_prompt, + image_path: relative, + width: job.width, + height: job.height, + seed: variant.seed, + steps: job.steps, + style: job.style, + profile: job.profile, + duration_ms: durationMs, + job_id: job.id, + variant_index: variant.variant_index, + prompt_mode: job.prompt_mode, + parameters: { + width: Number(job.width), + height: Number(job.height), + steps: Number(job.steps), + style: job.style, + profile: job.profile, + prompt_mode: job.prompt_mode, + seed_lock: Number(job.seed_lock || 0) === 1, + variants: Number(job.variants), + }, + }); + db.prepare(`UPDATE flux_prompt_job_variants SET status = 'completed', phase = 'completed', message = ?, image_id = ?, + duration_ms = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run(`Variante ${variant.variant_index} ist fertig.`, image.id, durationMs, variant.id); + return image; +} + +async function variantCancelled(variantId) { + const row = db.prepare('SELECT cancel_requested FROM flux_prompt_job_variants WHERE id = ?').get(variantId); + return Number(row?.cancel_requested || 0) === 1; +} + +async function runVariant(variantId, { resume = false } = {}) { + if (activeVariants.has(variantId)) return; + const variant = db.prepare('SELECT * FROM flux_prompt_job_variants WHERE id = ?').get(variantId); + if (!variant) return; + const job = fetchJobRow(variant.job_id); + if (!job || !ACTIVE_JOB_STATUSES.has(job.status)) return; + activeVariants.set(variantId, true); + const startedAt = parseDate(variant.started_at) || Date.now(); + db.prepare(`UPDATE flux_prompt_job_variants SET status = 'running', phase = ?, message = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`) + .run(resume ? 'resuming' : 'preparing', resume ? 'Vorhandener ComfyUI-Auftrag wird wieder aufgenommen …' : 'FLUX-Workflow wird vorbereitet …', variantId); + db.prepare(`UPDATE flux_prompt_jobs SET status = CASE WHEN status = 'cancelling' THEN status ELSE 'running' END, + phase = CASE WHEN status = 'cancelling' THEN 'cancelling' ELSE 'rendering' END, + started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.id); + try { + const settings = getSettings(); + const onPhase = (phase, message) => { + db.prepare(`UPDATE flux_prompt_job_variants SET phase = ?, message = ?, updated_at = datetime('now') WHERE id = ?`) + .run(String(phase || 'running'), String(message || 'FLUX arbeitet …').slice(0, 500), variantId); + }; + let result; + if (resume && variant.prompt_id) { + result = await waitForLocalFluxPrompt({ + promptId: variant.prompt_id, + settings, + timeoutMs: 300000, + onPhase, + isCancelled: () => variantCancelled(variantId), + }); + } else { + result = await generateWithLocalFlux({ + prompt: job.final_prompt, + settings: { ...settings, ai_model_photo_use_source_reference: '0', ai_model_photo_local_resolution: String(Math.max(job.width, job.height) >= 1024 ? 1024 : 768) }, + mode: 'prompt', + variantIndex: Math.max(0, Number(variant.variant_index) - 1), + sourceDataUrl: '', + width: Number(job.width), + height: Number(job.height), + seed: Number(variant.seed), + steps: Number(job.steps), + onPhase, + onSubmitted: async (promptId) => { + db.prepare(`UPDATE flux_prompt_job_variants SET prompt_id = ?, phase = 'submitted', message = 'An ComfyUI übergeben …', updated_at = datetime('now') WHERE id = ?`) + .run(String(promptId), variantId); + }, + isCancelled: () => variantCancelled(variantId), + }); + } + if (await variantCancelled(variantId)) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + saveResultImage(job, variant, result, startedAt); + } catch (error) { + const cancelled = error?.code === 'FLUX_CANCELLED' || await variantCancelled(variantId); + db.prepare(`UPDATE flux_prompt_job_variants SET status = ?, phase = ?, message = ?, error_message = ?, + completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run( + cancelled ? 'cancelled' : 'failed', + cancelled ? 'cancelled' : 'failed', + cancelled ? 'Variante wurde abgebrochen.' : 'Bildgenerierung fehlgeschlagen.', + cancelled ? null : String(error?.message || error).slice(0, 2000), + variantId, + ); + } finally { + activeVariants.delete(variantId); + updateParentJob(job.id); + queueMicrotask(() => processQueue().catch(() => {})); + } +} + +function getMaxParallelJobs() { + const settings = getSettings(); + return Math.max(1, Math.min(4, Math.round(Number(settings.flux_max_parallel_jobs) || 1))); +} + +async function processQueue() { + if (loopBusy) return; + loopBusy = true; + try { + const maxParallel = getMaxParallelJobs(); + while (activeVariants.size < maxParallel) { + const next = db.prepare(`SELECT v.id FROM flux_prompt_job_variants v + JOIN flux_prompt_jobs j ON j.id = v.job_id + WHERE v.status = 'queued' AND v.cancel_requested = 0 AND j.archived_at IS NULL AND j.status IN ('queued','running') + ORDER BY j.created_at, v.variant_index LIMIT 1`).get(); + if (!next) break; + runVariant(next.id).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 10)); + } + } finally { + loopBusy = false; + } +} + +function createJob({ + prompt, + width = 768, + height = 768, + seed = null, + steps = 4, + style = 'none', + profile = 'balanced', + promptMode = 'precise', + assistant = true, + variants = 1, + seedLock = false, + sourceJobId = null, + explicitSeeds = null, +} = {}) { + const cleanPrompt = normalizeWhitespace(prompt); + if (cleanPrompt.length < 3) throw new Error('Bitte einen aussagekräftigen Prompt eingeben.'); + if (cleanPrompt.length > 2000) throw new Error('Der Prompt darf höchstens 2.000 Zeichen lang sein.'); + const normalizedProfile = cleanProfile(profile); + const dimensions = applyProfile({ profile: normalizedProfile, width, height, steps }); + const normalizedStyle = cleanStyle(style); + const normalizedMode = cleanMode(promptMode); + const variantCount = cleanVariantCount(variants); + const baseSeed = normalizeSeed(seed); + const seeds = buildSeeds(baseSeed, variantCount, seedLock === true, explicitSeeds); + const id = randomUUID(); + const finalPrompt = buildFluxPrompt(cleanPrompt, { style: normalizedStyle, mode: normalizedMode, assistant: assistant !== false }); + const insert = db.transaction(() => { + db.prepare(`INSERT INTO flux_prompt_jobs + (id, prompt, final_prompt, width, height, steps, style, profile, prompt_mode, assistant, variants, seed_base, seed_lock, + status, phase, message, source_job_id, settings_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 'queued', ?, ?, '{}')`) + .run(id, cleanPrompt, finalPrompt, dimensions.width, dimensions.height, dimensions.steps, normalizedStyle, normalizedProfile, + normalizedMode, assistant !== false ? 1 : 0, variantCount, seeds[0], seedLock === true ? 1 : 0, + `${variantCount} Variante${variantCount === 1 ? '' : 'n'} wartet${variantCount === 1 ? '' : 'en'} auf FLUX.`, sourceJobId || null); + const stmt = db.prepare(`INSERT INTO flux_prompt_job_variants + (id, job_id, variant_index, seed, status, phase, message) VALUES (?, ?, ?, ?, 'queued', 'queued', ?)`); + seeds.forEach((variantSeed, index) => stmt.run(randomUUID(), id, index + 1, variantSeed, `Variante ${index + 1} wartet.`)); + }); + insert(); + processQueue().catch(() => {}); + return getFluxPromptJob(id); +} + +export function enqueueFluxPromptJob(payload = {}) { + return createJob(payload); +} + +export function getFluxPromptJob(id) { + return publicJob(fetchJobRow(id)); +} + +function fluxStatusWhere(status = '') { + const normalized = String(status || '').trim(); + if (normalized === 'active') return { sql: "status IN ('queued','running','cancelling')", args: [] }; + if (normalized === 'completed') return { sql: "status IN ('completed','completed_with_errors')", args: [] }; + if (['failed', 'cancelled', 'queued', 'running', 'cancelling', 'completed', 'completed_with_errors'].includes(normalized)) { + return { sql: 'status = ?', args: [normalized] }; + } + return { sql: '', args: [] }; +} + +export function listFluxPromptJobs({ limit = 50, status = '', archived = false } = {}) { + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); + const statusClause = fluxStatusWhere(status); + const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL']; + if (statusClause.sql) where.push(statusClause.sql); + const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ?`) + .all(...statusClause.args, safeLimit); + return rows.map(row => publicJob(row)); +} + +export function paginateFluxPromptJobs({ page = 1, pageSize = 10, status = '', archived = false } = {}) { + const safePageSize = Math.max(5, Math.min(15, Number(pageSize) || 10)); + const requestedPage = Math.max(1, Number(page) || 1); + const statusClause = fluxStatusWhere(status); + const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL']; + if (statusClause.sql) where.push(statusClause.sql); + const whereSql = where.join(' AND '); + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE ${whereSql}`).get(...statusClause.args)?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${whereSql} ORDER BY created_at DESC LIMIT ? OFFSET ?`) + .all(...statusClause.args, safePageSize, (safePage - 1) * safePageSize); + return { + jobs: rows.map(row => publicJob(row)), + pagination: { page: safePage, page_size: safePageSize, total, total_pages: totalPages }, + }; +} + +function normalizeJobIds(ids = []) { + return [...new Set((Array.isArray(ids) ? ids : [ids]).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 500); +} + +export function archiveFluxPromptJobs(ids = []) { + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, archived: 0, skipped: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = datetime('now'), updated_at = datetime('now') + WHERE id IN (${placeholders}) AND archived_at IS NULL AND status NOT IN ('queued','running','cancelling')`).run(...jobIds); + return { ok: true, archived: Number(result.changes || 0), skipped: jobIds.length - Number(result.changes || 0) }; +} + +export function restoreFluxPromptJobs(ids = []) { + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, restored: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = NULL, updated_at = datetime('now') WHERE id IN (${placeholders}) AND archived_at IS NOT NULL`).run(...jobIds); + return { ok: true, restored: Number(result.changes || 0) }; +} + +export function deleteArchivedFluxPromptJobs(ids = [], { all = false } = {}) { + if (all) { + const result = db.prepare('DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').run(); + return { ok: true, deleted: Number(result.changes || 0) }; + } + const jobIds = normalizeJobIds(ids); + if (!jobIds.length) return { ok: true, deleted: 0 }; + const placeholders = jobIds.map(() => '?').join(','); + const result = db.prepare(`DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL AND id IN (${placeholders})`).run(...jobIds); + return { ok: true, deleted: Number(result.changes || 0) }; +} + +export function getFluxPromptJobSummary() { + const rows = db.prepare('SELECT status, COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NULL GROUP BY status').all(); + const jobs = Object.fromEntries(rows.map(row => [row.status, Number(row.count)])); + const archivedTotal = Number(db.prepare('SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').get()?.count || 0); + const variants = db.prepare(`SELECT + SUM(CASE WHEN v.status = 'running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN v.status = 'queued' THEN 1 ELSE 0 END) AS queued + FROM flux_prompt_job_variants v JOIN flux_prompt_jobs j ON j.id = v.job_id WHERE j.archived_at IS NULL`).get(); + return { + jobs, + archived_total: archivedTotal, + variants: { running: Number(variants?.running || 0), queued: Number(variants?.queued || 0) }, + active_workers: activeVariants.size, + max_parallel: getMaxParallelJobs(), + }; +} + +export async function cancelFluxPromptJob(id) { + const job = fetchJobRow(id); + if (!job) return null; + if (!ACTIVE_JOB_STATUSES.has(job.status)) return getFluxPromptJob(id); + const runningVariants = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE job_id = ? AND status = 'running'").all(id); + db.transaction(() => { + db.prepare("UPDATE flux_prompt_jobs SET status = 'cancelling', phase = 'cancelling', message = 'Abbruch wird ausgeführt …', updated_at = datetime('now') WHERE id = ?").run(id); + db.prepare("UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Vor Start abgebrochen.', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(id); + db.prepare("UPDATE flux_prompt_job_variants SET cancel_requested = 1, phase = 'cancelling', message = 'Abbruch angefordert …', updated_at = datetime('now') WHERE job_id = ? AND status = 'running'").run(id); + })(); + if (runningVariants.length > 0) { + const settings = getSettings(); + for (const variant of runningVariants) { + if (!variant.prompt_id) continue; + try { await cancelLocalFluxPrompt(variant.prompt_id, settings); } catch {} + } + } + updateParentJob(id); + return getFluxPromptJob(id); +} + +export function retryFluxPromptJob(id) { + const source = fetchJobRow(id); + if (!source) return null; + const variants = getVariantRows(id); + return createJob({ + prompt: source.prompt, + width: source.width, + height: source.height, + seed: source.seed_base, + steps: source.steps, + style: source.style, + profile: source.profile, + promptMode: source.prompt_mode, + assistant: Number(source.assistant || 0) === 1, + variants: source.variants, + seedLock: Number(source.seed_lock || 0) === 1, + sourceJobId: source.id, + explicitSeeds: variants.map(item => Number(item.seed)), + }); +} + +export function clearFluxPromptQueue() { + const jobIds = db.prepare(`SELECT DISTINCT job_id FROM flux_prompt_job_variants WHERE status = 'queued'`).all().map(row => row.job_id); + const result = db.prepare(`UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Aus Warteschlange entfernt.', + completed_at = datetime('now'), updated_at = datetime('now') WHERE status = 'queued'`).run(); + jobIds.forEach(updateParentJob); + return { ok: true, cleared_variants: Number(result.changes || 0), affected_jobs: jobIds.length }; +} + +export function startFluxPromptJobLoop() { + if (loopTimer) return; + const running = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE status = 'running'").all(); + for (const variant of running) { + if (variant.prompt_id) runVariant(variant.id, { resume: true }).catch(() => {}); + else db.prepare("UPDATE flux_prompt_job_variants SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart erneut eingereiht.', updated_at = datetime('now') WHERE id = ?").run(variant.id); + } + db.prepare("UPDATE flux_prompt_jobs SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart wieder aufgenommen.', updated_at = datetime('now') WHERE status = 'running' AND id NOT IN (SELECT job_id FROM flux_prompt_job_variants WHERE status = 'running')").run(); + loopTimer = setInterval(() => processQueue().catch(() => {}), 1000); + loopTimer.unref?.(); + processQueue().catch(() => {}); +} diff --git a/lib/ai-local-images.mjs b/lib/ai-local-images.mjs new file mode 100644 index 0000000..f7bafeb --- /dev/null +++ b/lib/ai-local-images.mjs @@ -0,0 +1,466 @@ +import { join, isAbsolute, dirname } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { randomBytes, randomUUID } from 'crypto'; + +const ROOT_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = join(ROOT_DIR, '..'); +const DEFAULT_WORKFLOW_RELATIVE = 'local-ai/workflows/vendoo_flux_schnell_api.json'; + +function toPosix(path) { + return String(path || '').replace(/\\/g, '/'); +} + +function resolveWorkflowPath(pathValue = '') { + const trimmed = String(pathValue || '').trim(); + if (!trimmed) return join(PROJECT_DIR, DEFAULT_WORKFLOW_RELATIVE); + return isAbsolute(trimmed) ? trimmed : join(PROJECT_DIR, trimmed); +} + +function getEnvBool(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase()); +} + +export function generateLocalImageToken() { + return randomBytes(24).toString('base64url'); +} + + + +export function getLocalImageConfig(settings = {}) { + const workflowRelative = String(settings.ai_model_photo_local_workflow_path || process.env.LOCAL_IMAGE_WORKFLOW_PATH || DEFAULT_WORKFLOW_RELATIVE).trim() || DEFAULT_WORKFLOW_RELATIVE; + const workflowPath = resolveWorkflowPath(workflowRelative); + return { + enabled: getEnvBool(settings.ai_model_photo_local_enabled ?? process.env.LOCAL_IMAGE_ENABLED, true), + url: String(settings.ai_model_photo_local_url || process.env.LOCAL_IMAGE_URL || 'http://127.0.0.1:8188').trim().replace(/\/$/, ''), + token: String(settings.ai_model_photo_local_token || process.env.LOCAL_IMAGE_TOKEN || '').trim(), + workflow_path: workflowPath, + workflow_relative: toPosix(workflowRelative), + install_path: String(settings.ai_model_photo_local_install_path || process.env.LOCAL_IMAGE_INSTALL_PATH || '').trim(), + require_token: getEnvBool(settings.ai_model_photo_local_require_token ?? process.env.LOCAL_IMAGE_REQUIRE_TOKEN, true), + allow_remote: getEnvBool(process.env.LOCAL_IMAGE_ALLOW_REMOTE, false), + }; +} + +function assertSafeLocalUrl(config) { + let parsed; + try { parsed = new URL(config.url); } catch { throw new Error('Ungültige lokale Bildserver-URL.'); } + const host = String(parsed.hostname || '').toLowerCase(); + const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host); + if (!loopback && !config.allow_remote) { + throw new Error('Aus Sicherheitsgründen sind für lokalen FLUX standardmäßig nur localhost/127.0.0.1 erlaubt.'); + } + return { loopback, host }; +} + +function getAuthHeaders(config) { + const headers = {}; + if (config.token) { + headers['Authorization'] = `Bearer ${config.token}`; + headers['X-API-Key'] = config.token; + } + return headers; +} + +function getHeaders(config) { + return { ...getAuthHeaders(config), 'Content-Type': 'application/json' }; +} + + +async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +function replaceDeep(value, replacements) { + if (typeof value === 'string') { + let out = value; + for (const [key, val] of Object.entries(replacements)) { + out = out.split(key).join(String(val)); + } + return out; + } + if (Array.isArray(value)) return value.map(item => replaceDeep(item, replacements)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceDeep(v, replacements)])); + } + return value; +} + +function firstImageFromHistory(history = {}) { + for (const node of Object.values(history?.outputs || {})) { + if (Array.isArray(node?.images) && node.images.length) return node.images[0]; + } + return null; +} + +async function pollHistory(config, promptId, timeoutMs = 300000, onProgress = null, { onPhase = null, isCancelled = null } = {}) { + const started = Date.now(); + let lastPayload = null; + while (Date.now() - started < timeoutMs) { + if (typeof isCancelled === 'function' && await isCancelled()) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + const elapsedSeconds = Math.max(0, Math.round((Date.now() - started) / 1000)); + const message = elapsedSeconds < 8 + ? 'ComfyUI übernimmt den Auftrag …' + : `FLUX rendert das Bild seit ${elapsedSeconds} Sekunden …`; + if (typeof onPhase === 'function') onPhase('rendering', message, { elapsed_seconds: elapsedSeconds }); + if (typeof onProgress === 'function') { + const estimated = Math.min(88, 24 + Math.round((elapsedSeconds / 150) * 60)); + onProgress(estimated, message); + } + const response = await fetchWithTimeout(`${config.url}/history/${promptId}`, { headers: getHeaders(config) }, 12000); + const payload = await response.json().catch(() => ({})); + lastPayload = payload?.[promptId] || payload; + const image = firstImageFromHistory(lastPayload); + if (image) return { history: lastPayload, image }; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error('Lokale FLUX/ComfyUI-Generierung hat nach fünf Minuten das Zeitlimit überschritten.'); +} + +async function fetchComfyImageAsBase64(config, imageInfo) { + const params = new URLSearchParams({ filename: imageInfo.filename, subfolder: imageInfo.subfolder || '', type: imageInfo.type || 'output' }); + const response = await fetchWithTimeout(`${config.url}/view?${params.toString()}`, { headers: getHeaders(config) }, 60000); + if (!response.ok) throw new Error(`ComfyUI-Bildabruf fehlgeschlagen (${response.status})`); + return Buffer.from(await response.arrayBuffer()).toString('base64'); +} + +function parseDataUrl(dataUrl = '') { + const match = String(dataUrl || '').match(/^data:([^;,]+);base64,(.+)$/s); + if (!match) return null; + return { mime: match[1] || 'image/jpeg', buffer: Buffer.from(match[2], 'base64') }; +} + +async function uploadSourceImage(config, sourceDataUrl, onProgress = null) { + const parsed = parseDataUrl(sourceDataUrl); + if (!parsed?.buffer?.length) return null; + if (typeof onProgress === 'function') onProgress(8, 'Produktfotoreferenz wird an ComfyUI übertragen …'); + const extension = parsed.mime.includes('png') ? 'png' : parsed.mime.includes('webp') ? 'webp' : 'jpg'; + const filename = `vendoo-reference-${randomUUID()}.${extension}`; + const form = new FormData(); + form.append('image', new Blob([parsed.buffer], { type: parsed.mime }), filename); + form.append('type', 'input'); + form.append('overwrite', 'true'); + const response = await fetchWithTimeout(`${config.url}/upload/image`, { + method: 'POST', + headers: getAuthHeaders(config), + body: form, + }, 60000); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.name) { + const detail = payload?.error || payload?.message || `HTTP ${response.status}`; + throw new Error(`Produktfotoreferenz konnte nicht an ComfyUI übertragen werden: ${detail}`); + } + const imageName = payload.subfolder ? `${payload.subfolder}/${payload.name}` : payload.name; + return { imageName, payload }; +} + +function applySourceImageConditioning(workflow, imageName, mode = 'model', referenceStrength = 70, resolution = 768) { + if (!imageName || !workflow?.['7']?.inputs || !workflow?.['3']) return { workflow, used: false, denoise: 1 }; + const strength = Math.max(30, Math.min(90, Number(referenceStrength || 70))); + const targetSize = Number(resolution) === 1024 ? 1024 : 768; + const baseDenoise = mode === 'flatlay' ? 0.52 : mode === 'ghost_mannequin' ? 0.64 : 0.80; + const denoise = Math.max(0.42, Math.min(0.88, Number((baseDenoise - ((strength - 60) * 0.0035)).toFixed(3)))); + workflow['10'] = { inputs: { image: imageName, upload: 'image' }, class_type: 'LoadImage' }; + workflow['11'] = { + inputs: { upscale_method: 'lanczos', width: targetSize, height: targetSize, crop: 'disabled', image: ['10', 0] }, + class_type: 'ImageScale', + }; + workflow['12'] = { inputs: { pixels: ['11', 0], vae: ['3', 2] }, class_type: 'VAEEncode' }; + workflow['7'].inputs.latent_image = ['12', 0]; + workflow['7'].inputs.denoise = denoise; + return { workflow, used: true, denoise }; +} + +export async function getLocalImageStatus(settings = {}) { + const config = getLocalImageConfig(settings); + const workflowExists = existsSync(config.workflow_path); + const configuredInstallRoot = config.install_path + ? (isAbsolute(config.install_path) ? config.install_path : join(PROJECT_DIR, config.install_path)) + : join(PROJECT_DIR, 'local-ai', 'comfyui'); + const portableRoot = existsSync(join(configuredInstallRoot, 'ComfyUI', 'main.py')) + ? configuredInstallRoot + : join(configuredInstallRoot, 'ComfyUI_windows_portable'); + const comfyMain = join(portableRoot, 'ComfyUI', 'main.py'); + const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'); + const status = { + enabled: config.enabled, + url: config.url, + token_set: !!config.token, + require_token: config.require_token, + workflow_path: config.workflow_relative, + workflow_exists: workflowExists, + install_path: config.install_path, + comfy_installed: existsSync(comfyMain), + model_installed: existsSync(modelPath), + model_path: modelPath, + reachable: false, + installed: existsSync(comfyMain), + localhost_only: !config.allow_remote, + reference_image_supported: true, + message: '', + }; + try { + const security = assertSafeLocalUrl(config); + status.loopback = security.loopback; + const res = await fetchWithTimeout(`${config.url}/system_stats`, { headers: getHeaders(config) }, 8000); + status.reachable = res.ok; + status.installed = res.ok; + const payload = await res.json().catch(() => ({})); + status.system = payload?.system || payload || null; + status.message = res.ok + ? `Lokaler Bildserver erreichbar${workflowExists ? '' : ' · Workflow-Datei fehlt noch'}.` + : `Lokaler Bildserver antwortet mit HTTP ${res.status}.`; + } catch (error) { + status.message = `Lokaler Bildserver nicht erreichbar: ${error.message || error}`; + } + return status; +} + +export async function generateWithLocalFlux({ + prompt, + settings = {}, + mode = 'model', + variantIndex = 0, + sourceDataUrl = '', + onProgress = null, + onPhase = null, + onSubmitted = null, + isCancelled = null, + width = null, + height = null, + seed = null, + steps = null, +}) { + const config = getLocalImageConfig(settings); + const throwIfCancelled = async () => { + if (typeof isCancelled === 'function' && await isCancelled()) { + const error = new Error('FLUX-Auftrag wurde abgebrochen.'); + error.code = 'FLUX_CANCELLED'; + throw error; + } + }; + await throwIfCancelled(); + if (!config.enabled) throw new Error('Lokale FLUX-Bildgenerierung ist deaktiviert.'); + assertSafeLocalUrl(config); + if (!existsSync(config.workflow_path)) throw new Error(`Workflow-Datei nicht gefunden: ${config.workflow_relative}`); + if (typeof onPhase === 'function') onPhase('preparing', 'Lokaler FLUX-Workflow wird vorbereitet …'); + if (typeof onProgress === 'function') onProgress(3, 'Lokaler FLUX-Workflow wird vorbereitet …'); + const workflowText = readFileSync(config.workflow_path, 'utf8').replace(/^/, ''); + let raw; + try { + raw = JSON.parse(workflowText); + } catch (error) { + throw new Error(`Lokaler FLUX-Workflow ist kein gültiges JSON: ${error.message || error}`); + } + const replacements = { + '__PROMPT__': prompt, + '__NEGATIVE_PROMPT__': mode === 'prompt' ? '' : 'nsfw, nudity, lingerie, child, low quality, extra limbs, cropped garment, wrong garment, different clothing, changed logo, changed color', + '__SEED__': Number.isFinite(Number(seed)) ? Math.max(0, Math.min(2147483647, Number(seed))) : Math.floor(Math.random() * 2147483647), + '__MODE__': mode, + '__VARIANT_INDEX__': variantIndex + 1, + '__SOURCE_IMAGE__': sourceDataUrl || '', + '__CLIENT_ID__': 'vendoo', + }; + let workflow = replaceDeep(raw, replacements); + if (workflow?.['7']?.inputs) workflow['7'].inputs.seed = replacements.__SEED__; + const resolution = Number(settings.ai_model_photo_local_resolution) === 1024 ? 1024 : 768; + const targetWidth = Number(width) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(width) / 8) * 8)) : resolution; + const targetHeight = Number(height) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(height) / 8) * 8)) : resolution; + if (workflow?.['6']?.inputs) { + workflow['6'].inputs.width = targetWidth; + workflow['6'].inputs.height = targetHeight; + } + const targetSteps = Number.isFinite(Number(steps)) ? Math.max(4, Math.min(20, Math.round(Number(steps)))) : null; + if (targetSteps && workflow?.['7']?.inputs) workflow['7'].inputs.steps = targetSteps; + if (mode === 'prompt' && workflow?.['7']?.inputs) { + workflow['7'].inputs.cfg = 1; + workflow['7'].inputs.sampler_name = 'euler'; + workflow['7'].inputs.scheduler = 'simple'; + workflow['7'].inputs.denoise = 1; + } + const useReference = getEnvBool(settings.ai_model_photo_use_source_reference, true) && !!sourceDataUrl; + let reference = { used: false, denoise: 1 }; + if (useReference) { + const uploaded = await uploadSourceImage(config, sourceDataUrl, onProgress); + if (uploaded?.imageName) reference = applySourceImageConditioning(workflow, uploaded.imageName, mode, settings.ai_model_photo_reference_strength || 70, resolution); + } + if (typeof onPhase === 'function') onPhase('submitting', reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.'); + if (typeof onProgress === 'function') onProgress(16, reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.'); + await throwIfCancelled(); + const submit = await fetchWithTimeout(`${config.url}/prompt`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({ client_id: `vendoo-${randomUUID()}`, prompt: workflow }), + }, 30000); + const submitPayload = await submit.json().catch(() => ({})); + if (!submit.ok || !submitPayload.prompt_id) { + const message = submitPayload?.error?.message || submitPayload?.error || submitPayload?.message || `HTTP ${submit.status}`; + throw new Error(`ComfyUI-Prompt fehlgeschlagen: ${message}`); + } + if (typeof onSubmitted === 'function') await onSubmitted(submitPayload.prompt_id); + try { + await throwIfCancelled(); + } catch (error) { + try { await cancelLocalFluxPrompt(submitPayload.prompt_id, settings); } catch {} + throw error; + } + if (typeof onPhase === 'function') onPhase('submitted', 'Auftrag wurde an FLUX übergeben …', { prompt_id: submitPayload.prompt_id }); + if (typeof onProgress === 'function') onProgress(22, 'Auftrag wurde an FLUX übergeben …'); + const done = await pollHistory(config, submitPayload.prompt_id, 300000, onProgress, { onPhase, isCancelled }); + if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …'); + if (typeof onProgress === 'function') onProgress(92, 'Fertiges Bild wird von ComfyUI geladen …'); + const base64 = await fetchComfyImageAsBase64(config, done.image); + if (typeof onPhase === 'function') onPhase('completed', 'Lokales FLUX-Bild wurde erzeugt.'); + if (typeof onProgress === 'function') onProgress(100, 'Lokales FLUX-Bild wurde erzeugt.'); + return { + base64, + model: 'flux-schnell-local', + provider: 'local', + label: 'Lokales FLUX / ComfyUI', + free: true, + prompt_id: submitPayload.prompt_id, + reference_used: reference.used, + reference_denoise: reference.denoise, + resolution, + width: targetWidth, + height: targetHeight, + seed: replacements.__SEED__, + steps: targetSteps || workflow?.['7']?.inputs?.steps || 6, + }; +} + + +export async function waitForLocalFluxPrompt({ promptId, settings = {}, timeoutMs = 300000, onPhase = null, isCancelled = null } = {}) { + const config = getLocalImageConfig(settings); + if (!promptId) throw new Error('ComfyUI-Prompt-ID fehlt.'); + assertSafeLocalUrl(config); + const done = await pollHistory(config, String(promptId), timeoutMs, null, { onPhase, isCancelled }); + if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …'); + const base64 = await fetchComfyImageAsBase64(config, done.image); + return { base64, prompt_id: String(promptId), history: done.history, image: done.image }; +} + +export async function interruptLocalFlux(settings = {}) { + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + const response = await fetchWithTimeout(`${config.url}/interrupt`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({}), + }, 15000); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Abbruch fehlgeschlagen (${response.status})`); + return { ok: true, payload }; +} + +function comfyQueuePromptIds(items = []) { + return (Array.isArray(items) ? items : []) + .map(item => Array.isArray(item) ? item[1] : item?.prompt_id || item?.id) + .filter(Boolean) + .map(String); +} + +export async function cancelLocalFluxPrompt(promptId, settings = {}) { + const id = String(promptId || '').trim(); + if (!id) return { ok: false, mode: 'not_submitted' }; + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + + const queueResponse = await fetchWithTimeout(`${config.url}/queue`, { headers: getHeaders(config) }, 12000); + const queue = await queueResponse.json().catch(() => ({})); + if (!queueResponse.ok) throw new Error(queue?.error || queue?.message || `ComfyUI-Queue konnte nicht gelesen werden (${queueResponse.status})`); + + const runningIds = comfyQueuePromptIds(queue?.queue_running); + const pendingIds = comfyQueuePromptIds(queue?.queue_pending); + if (pendingIds.includes(id)) { + const response = await fetchWithTimeout(`${config.url}/queue`, { + method: 'POST', + headers: getHeaders(config), + body: JSON.stringify({ delete: [id] }), + }, 15000); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Warteschlangenauftrag konnte nicht entfernt werden (${response.status})`); + return { ok: true, mode: 'dequeued', prompt_id: id, payload }; + } + if (runningIds.includes(id)) { + const interrupted = await interruptLocalFlux(settings); + return { ...interrupted, mode: 'interrupted', prompt_id: id }; + } + return { ok: true, mode: 'already_finished_or_missing', prompt_id: id }; +} + +export async function getLocalFluxRuntimeInfo(settings = {}, { historyLimit = 20 } = {}) { + const config = getLocalImageConfig(settings); + assertSafeLocalUrl(config); + const fetchJson = async (path, timeout = 12000) => { + const response = await fetchWithTimeout(`${config.url}${path}`, { headers: getHeaders(config) }, timeout); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`${path} antwortet mit HTTP ${response.status}`); + return payload; + }; + const [systemResult, queueResult, historyResult] = await Promise.allSettled([ + fetchJson('/system_stats'), + fetchJson('/queue'), + fetchJson(`/history?max_items=${Math.max(1, Math.min(100, Number(historyLimit) || 20))}`), + ]); + const systemStats = systemResult.status === 'fulfilled' ? systemResult.value : null; + const queue = queueResult.status === 'fulfilled' ? queueResult.value : null; + const history = historyResult.status === 'fulfilled' ? historyResult.value : null; + const running = Array.isArray(queue?.queue_running) ? queue.queue_running.length : 0; + const pending = Array.isArray(queue?.queue_pending) ? queue.queue_pending.length : 0; + const historyItems = history && typeof history === 'object' ? Object.entries(history).slice(0, Math.max(1, Number(historyLimit) || 20)) : []; + return { + reachable: !!systemStats || !!queue || !!history, + url: config.url, + system_stats: systemStats, + queue, + history: Object.fromEntries(historyItems), + queue_summary: { running, pending, total: running + pending }, + errors: { + system_stats: systemResult.status === 'rejected' ? systemResult.reason?.message || String(systemResult.reason) : null, + queue: queueResult.status === 'rejected' ? queueResult.reason?.message || String(queueResult.reason) : null, + history: historyResult.status === 'rejected' ? historyResult.reason?.message || String(historyResult.reason) : null, + }, + }; +} + + +export function ensureLocalFluxBootstrap({ token = '', installPath = '' } = {}) { + const localDir = join(PROJECT_DIR, 'local-ai'); + const workflowsDir = join(localDir, 'workflows'); + mkdirSync(workflowsDir, { recursive: true }); + const workflowPath = join(workflowsDir, 'vendoo_flux_schnell_api.json'); + if (!existsSync(workflowPath)) { + const workflow = { + '3': { inputs: { ckpt_name: 'flux1-schnell-fp8.safetensors' }, class_type: 'CheckpointLoaderSimple' }, + '4': { inputs: { text: '__PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' }, + '5': { inputs: { text: '__NEGATIVE_PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' }, + '6': { inputs: { width: 1024, height: 1024, batch_size: 1 }, class_type: 'EmptyLatentImage' }, + '7': { inputs: { seed: '__SEED__', steps: 6, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1, model: ['3', 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] }, class_type: 'KSampler' }, + '8': { inputs: { samples: ['7', 0], vae: ['3', 2] }, class_type: 'VAEDecode' }, + '9': { inputs: { filename_prefix: 'Vendoo/flux_prompt', images: ['8', 0] }, class_type: 'SaveImage' }, + }; + writeFileSync(workflowPath, JSON.stringify(workflow, null, 2), 'utf8'); + } + const envLocalPath = join(localDir, '.env.local-flux'); + if (!existsSync(envLocalPath)) { + writeFileSync(envLocalPath, [ + 'LOCAL_IMAGE_URL=http://127.0.0.1:8188', + `LOCAL_IMAGE_TOKEN=${token || generateLocalImageToken()}`, + `LOCAL_IMAGE_WORKFLOW_PATH=${DEFAULT_WORKFLOW_RELATIVE}`, + `LOCAL_IMAGE_INSTALL_PATH=${installPath}`, + ].join('\n') + '\n', 'utf8'); + } + return { workflow_path: toPosix(DEFAULT_WORKFLOW_RELATIVE), env_file: toPosix('local-ai/.env.local-flux') }; +} diff --git a/lib/ai-local.mjs b/lib/ai-local.mjs new file mode 100644 index 0000000..f654a54 --- /dev/null +++ b/lib/ai-local.mjs @@ -0,0 +1,64 @@ +export const MODELS = [ + { id: 'llama3.2-vision', name: 'Llama 3.2 Vision (11B)' }, + { id: 'llava', name: 'LLaVA 1.6 (7B)' }, + { id: 'moondream', name: 'Moondream 2 (1.8B, schnell)' }, + { id: 'gemma3', name: 'Gemma 3 (Vision)' }, +]; + +export async function generate(images, systemPrompt, model) { + const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + const modelId = model || MODELS[0].id; + + const ollamaImages = images.map(img => img.base64); + + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + images: ollamaImages, + }, + ], + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama Fehler (${res.status}): ${text.slice(0, 200)}`); + } + + const data = await res.json(); + const text = data.message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt, model) { + const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + const modelId = model || MODELS[0].id; + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama Fehler (${res.status}): ${text.slice(0, 200)}`); + } + const data = await res.json(); + return String(data.message?.content || '').trim(); +} diff --git a/lib/ai-model-jobs.mjs b/lib/ai-model-jobs.mjs new file mode 100644 index 0000000..5864cfa --- /dev/null +++ b/lib/ai-model-jobs.mjs @@ -0,0 +1,127 @@ +import { + createAiModelGeneration, + updateAiModelGeneration, + createAiModelPhoto, + createAiModelJob, + updateAiModelJob, + getAiModelJob, + getPendingAiModelJobs, +} from './db.mjs'; +import { generateAiModelPhotos } from './ai-model-photos.mjs'; + +let processing = false; +let loopStarted = false; + +async function processOneJob(job) { + let lastProgress = -1; + let lastMessage = ''; + const reportProgress = (progress, message = '') => { + const normalized = Math.max(1, Math.min(99, Math.round(Number(progress || 0)))); + const cleanMessage = String(message || '').trim(); + if (normalized === lastProgress && cleanMessage === lastMessage) return; + lastProgress = normalized; + lastMessage = cleanMessage; + updateAiModelJob(job.id, { + status: 'running', + progress: normalized, + progress_message: cleanMessage || 'AI-Bild wird erzeugt …', + error_message: null, + }); + }; + + reportProgress(4, 'Job wird vorbereitet …'); + try { + const payload = job.payload_json || {}; + const result = await generateAiModelPhotos({ ...payload, settings: payload.settings || {} }, reportProgress); + reportProgress(94, 'Ergebnis wird gespeichert …'); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || result.mode || 'flatlay', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || payload.mode || 'flatlay', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + mode: payload.mode || result.mode || 'flatlay', + }); + updateAiModelJob(job.id, { + status: 'completed', + progress: 100, + progress_message: 'AI-Bilder sind fertig.', + generation_id: generation.id, + }); + return { generation, photos, provider: result.provider || null }; + } catch (err) { + updateAiModelJob(job.id, { + status: 'failed', + progress: 100, + progress_message: 'AI-Bildgenerierung fehlgeschlagen.', + error_message: err.message || 'Unbekannter Fehler', + }); + throw err; + } +} +async function processQueue() { + if (processing) return; + processing = true; + try { + const pending = getPendingAiModelJobs(5); + for (const job of pending) { + try { + await processOneJob(job); + } catch {} + } + } finally { + processing = false; + } +} + +export function startAiModelJobLoop() { + if (loopStarted) return; + loopStarted = true; + setInterval(() => { processQueue().catch(() => {}); }, 2500).unref?.(); +} + +export function enqueueAiModelJob(payload = {}) { + const job = createAiModelJob({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: payload.mode || 'model', + preset: payload.preset || 'mixed', + variants: payload.variants || 1, + status: 'queued', + progress: 0, + payload_json: payload, + }); + processQueue().catch(() => {}); + return job; +} + +export function getAiModelJobWithRelations(id) { + return getAiModelJob(id); +} diff --git a/lib/ai-model-photos.mjs b/lib/ai-model-photos.mjs new file mode 100644 index 0000000..78ff0c1 --- /dev/null +++ b/lib/ai-model-photos.mjs @@ -0,0 +1,664 @@ +import OpenAI from 'openai'; +import { join, extname } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; +import { generateWithLocalFlux, getLocalImageConfig } from './ai-local-images.mjs'; + +const uploadsDir = UPLOADS_DIR; +const modelDir = join(uploadsDir, 'ai-models'); +mkdirSync(modelDir, { recursive: true }); + +const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models?output_modalities=image&sort=newest'; +const OPENROUTER_IMAGES_URL = 'https://openrouter.ai/api/v1/images/generations'; + +let openaiClient; +function getOpenAIClient() { + if (!process.env.OPENAI_API_KEY) throw new Error('OPENAI_API_KEY fehlt.'); + if (!openaiClient) openaiClient = new OpenAI(); + return openaiClient; +} + +const BLOCKED_KEYWORDS = [ + 'unterwäsche', 'lingerie', 'dessous', 'bikini', 'bademode', 'badeanzug', 'bh', 'slip', 'string', 'tanga', + 'babydoll', 'korsage', 'corsage', 'fetisch', 'latex', 'erotik', 'transparent', 'see-through', 'mesh', 'sexy' +]; +const KIDS_KEYWORDS = ['baby', 'kinder', 'kind', 'jungen', 'mädchen', 'maedchen', 'kleinkind', 'kids']; +const CLOTHING_KEYWORDS = ['shirt', 't-shirt', 'top', 'bluse', 'hemd', 'hoodie', 'pullover', 'sweatshirt', 'jacke', 'mantel', 'kleid', 'rock', 'hose', 'jeans', 'cardigan', 'sakko', 'blazer', 'overall', 'jumpsuit', 'schuhe', 'sneaker', 'tasche', 'bekleidung', 'fashion', 'mode']; +const PRESET_VARIANTS = { + studio: ['helles Studio mit weichem Schattenwurf', 'neutraler Editorial-Look vor hellem Hintergrund', 'cleaner Studio-Catalog-Look'], + indoor: ['natürliche Indoor-Szene in modernem Wohnraum', 'ruhige Boutique-Atmosphäre mit natürlichem Licht', 'casual Lifestyle-Interior mit sauberem Hintergrund'], + outdoor: ['ruhige urbane Außenumgebung bei natürlichem Tageslicht', 'dezente Outdoor-Szene mit unscharfem Hintergrund', 'cleaner Streetstyle-Look mit weichem Licht'], + mixed: ['helles Studio mit weichem Schattenwurf', 'natürliche Indoor-Szene in modernem Wohnraum', 'ruhige urbane Außenumgebung bei natürlichem Tageslicht'], +}; + +function safeJsonParse(text, fallback = null) { + try { return JSON.parse(text); } catch {} + const match = String(text || '').match(/\{[\s\S]*\}/); + if (!match) return fallback; + try { return JSON.parse(match[0]); } catch { return fallback; } +} + +function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } +function numeric(val) { const num = Number(val ?? 0); return Number.isFinite(num) ? num : 0; } + +function normalizeListingContext(input = {}) { + return { + title: String(input.title || '').trim(), + category: String(input.category || '').trim(), + brand: String(input.brand || '').trim(), + color: String(input.color || '').trim(), + size: String(input.size || '').trim(), + condition: String(input.condition || '').trim(), + seller_notes: String(input.seller_notes || '').trim(), + platform: String(input.platform || '').trim() || 'vinted', + }; +} + +function keywordFlags(context) { + const hay = `${context.title} ${context.category} ${context.seller_notes}`.toLowerCase(); + return { + blocked: BLOCKED_KEYWORDS.filter(word => hay.includes(word)), + kids: KIDS_KEYWORDS.filter(word => hay.includes(word)), + clothing: CLOTHING_KEYWORDS.some(word => hay.includes(word)), + }; +} + +function heuristicAnalysis(context) { + const flags = keywordFlags(context); + const garmentType = CLOTHING_KEYWORDS.find(word => (`${context.title} ${context.category}`).toLowerCase().includes(word)) || 'Kleidungsstück'; + return { + category: context.category || 'Mode', + garmentType, + color: context.color || null, + pattern: null, + material: null, + fit: null, + style: null, + age_group: flags.kids.length ? 'kids' : 'adult', + suitable_for_model_photos: flags.blocked.length === 0, + has_person: false, + confidence: 'low', + notes: flags.clothing ? 'Heuristik erkennt Modeartikel.' : 'Heuristik ohne visuelle Analyse.', + }; +} + +function getUploadPath(filename) { return join(uploadsDir, String(filename || '')); } + +function fileToDataUrl(filename) { + const full = getUploadPath(filename); + const ext = extname(full).toLowerCase(); + const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; + const b64 = readFileSync(full).toString('base64'); + return `data:${mime};base64,${b64}`; +} + +function parseOpenRouterModel(model = {}) { + const inputModalities = model.architecture?.input_modalities || []; + const outputModalities = model.architecture?.output_modalities || []; + return { + id: model.id, + name: model.name || model.id, + description: model.description || '', + created: model.created || null, + input_modalities: inputModalities, + output_modalities: outputModalities, + pricing: model.pricing || {}, + supported_parameters: model.supported_parameters || [], + is_free: isOpenRouterFreeModel(model), + supports_image_input: inputModalities.includes('image'), + supports_image_output: outputModalities.includes('image'), + }; +} + +function priceIsZero(value) { + if (value === undefined || value === null || value === '') return true; + const number = Number(value); + return Number.isFinite(number) && number === 0; +} + +function isOpenRouterFreeModel(model = {}) { + const id = String(model.id || '').toLowerCase(); + const name = String(model.name || '').toLowerCase(); + if (id.endsWith(':free') || name.includes('(free)')) return true; + + const pricing = model.pricing || {}; + // Nur Preise bewerten, die für eine normale Bildanforderung relevant sind. + // Optionale Web-/Audio-Preise dürfen ein ansonsten kostenloses Bildmodell nicht ausschließen. + const relevantKeys = ['request', 'prompt', 'completion', 'image']; + const present = relevantKeys.filter(key => pricing[key] !== undefined && pricing[key] !== null && pricing[key] !== ''); + return present.length > 0 && present.every(key => priceIsZero(pricing[key])); +} + +function getOpenRouterHeaders() { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY fehlt.'); + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }; + if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL; + if (process.env.OPENROUTER_APP_NAME) headers['X-Title'] = process.env.OPENROUTER_APP_NAME; + return headers; +} + +export async function fetchOpenRouterImageModels({ freeOnly = true } = {}) { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { ...getOpenRouterHeaders(), 'Cache-Control': 'no-cache' }, + cache: 'no-store', + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`OpenRouter Models API Fehler (${response.status})${body ? `: ${body.slice(0, 180)}` : ''}`); + } + const payload = await response.json(); + const list = Array.isArray(payload?.data) ? payload.data.map(parseOpenRouterModel) : []; + const imageModels = list.filter(model => model.supports_image_output); + const filtered = freeOnly ? imageModels.filter(model => model.is_free) : imageModels; + return filtered.sort((a, b) => Number(b.created || 0) - Number(a.created || 0)); +} + +export async function getOpenRouterImageDiagnostics() { + const allModels = await fetchOpenRouterImageModels({ freeOnly: false }); + const freeModels = allModels.filter(model => model.is_free); + return { + image_model_count: allModels.length, + free_image_model_count: freeModels.length, + free_models: freeModels, + all_models: allModels, + message: freeModels.length + ? `${freeModels.length} kostenlose OpenRouter-Bildmodelle verfügbar.` + : 'Aktuell wurde kein kostenloses OpenRouter-Modell mit Bildausgabe gefunden.', + }; +} + +async function resolveImageProviders(settings = {}) { + const preference = String(settings.ai_model_photo_provider || 'auto').toLowerCase(); + const openrouterFreeOnly = String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const configuredOpenRouterModel = String(settings.ai_model_photo_openrouter_model || '').trim(); + const configuredOpenAiModel = String(settings.ai_model_photo_openai_model || 'gpt-image-1').trim() || 'gpt-image-1'; + const localConfig = getLocalImageConfig(settings); + const candidates = []; + const discoveryErrors = []; + + const allowLocal = preference === 'local' || preference === 'auto'; + const allowOpenRouter = preference === 'openrouter' || preference === 'auto'; + const allowOpenAi = preference === 'openai' || preference === 'auto'; + + if (allowLocal && localConfig.enabled && localConfig.url) { + candidates.push({ + provider: 'local', + model: 'flux-schnell-local', + label: 'Lokales FLUX / ComfyUI', + free: true, + local: true, + discovered_models: [], + }); + } else if (preference === 'local') { + discoveryErrors.push('Lokaler FLUX/ComfyUI-Provider ist nicht aktiviert.'); + } + + if (allowOpenRouter && process.env.OPENROUTER_API_KEY) { + try { + const models = await fetchOpenRouterImageModels({ freeOnly: openrouterFreeOnly }); + const ordered = []; + const configured = models.find(model => model.id === configuredOpenRouterModel); + if (configured) ordered.push(configured); + for (const model of models) { + if (!ordered.some(entry => entry.id === model.id)) ordered.push(model); + } + for (const model of ordered) { + candidates.push({ + provider: 'openrouter', + model: model.id, + label: model.name, + free: model.is_free, + supports_image_input: model.supports_image_input, + supported_parameters: model.supported_parameters || [], + discovered_models: models, + }); + } + if (!ordered.length) discoveryErrors.push(openrouterFreeOnly ? 'Aktuell sind keine kostenlosen OpenRouter-Bildmodelle mit Bildausgabe verfügbar.' : 'Keine passenden OpenRouter-Bildmodelle gefunden.'); + } catch (error) { + discoveryErrors.push(error.message || 'OpenRouter-Modellabfrage fehlgeschlagen.'); + } + } else if (preference === 'openrouter' && !process.env.OPENROUTER_API_KEY) { + discoveryErrors.push('OPENROUTER_API_KEY fehlt.'); + } + + if (allowOpenAi && process.env.OPENAI_API_KEY) { + candidates.push({ + provider: 'openai', + model: configuredOpenAiModel, + label: configuredOpenAiModel, + free: false, + discovered_models: [], + }); + } else if (preference === 'openai' && !process.env.OPENAI_API_KEY) { + discoveryErrors.push('OPENAI_API_KEY fehlt.'); + } + + if (!candidates.length) { + throw new Error(discoveryErrors.filter(Boolean).join(' | ') || 'Kein Bildprovider konfiguriert.'); + } + return candidates; +} + +export async function analyzeGarmentPhotos(photos = [], contextInput = {}) { + const context = normalizeListingContext(contextInput); + const base = heuristicAnalysis(context); + if (!photos.length || !process.env.OPENAI_API_KEY) return base; + + const content = photos.slice(0, 4).map(photo => ({ type: 'image_url', image_url: { url: fileToDataUrl(photo), detail: 'high' } })); + content.push({ + type: 'text', + text: `Analysiere diese Produktfotos für ein Second-Hand-Listing. Antworte ausschließlich als JSON mit den Feldern: category, garmentType, color, pattern, material, fit, style, age_group (adult|kids|unknown), suitable_for_model_photos (boolean), has_person (boolean), confidence, notes. Kontext: ${JSON.stringify(context)}. If a person is visible set has_person=true. If the item appears to be underwear, swimwear, lingerie, transparent erotic wear or child clothing, mark suitable_for_model_photos=false.` + }); + + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0.1, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Du bist eine strenge Safety- und Produktanalyse für einen Mode-Listing-Generator.' }, + { role: 'user', content }, + ], + max_tokens: 500, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed ? { ...base, ...parsed } : base; + } catch { + return base; + } +} + +export async function precheckAiModelPhotos(input = {}, settings = {}) { + const context = normalizeListingContext(input); + const photos = Array.isArray(input.photos) ? input.photos.filter(Boolean) : []; + const variants = clamp(Number(input.variants || settings.ai_model_photo_default_variants || 1), 1, 3); + const preset = ['studio', 'indoor', 'outdoor', 'mixed'].includes(input.preset) ? input.preset : (settings.ai_model_photo_default_preset || 'mixed'); + const mode = ['ghost_mannequin', 'flatlay'].includes(input.mode) ? input.mode : (settings.ai_model_photo_default_mode || 'flatlay'); + const flags = keywordFlags(context); + if (!photos.length) return { allowed: false, reason: 'Keine Fotos vorhanden.', context, variants, preset, mode, checks: [] }; + if (flags.blocked.length) return { allowed: false, reason: `Blockierter Produkttyp erkannt: ${flags.blocked[0]}.`, context, variants, preset, mode, checks: ['block_underwear'] }; + if (flags.kids.length) return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['block_kids'] }; + const analysis = await analyzeGarmentPhotos(photos, context); + if (String(analysis.age_group || '').toLowerCase() === 'kids') return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['vision_kids'], analysis }; + if (analysis.suitable_for_model_photos === false) return { allowed: false, reason: 'Der Artikel ist laut Sicherheitsprüfung nicht für AI-Model-Fotos geeignet.', context, variants, preset, mode, checks: ['vision_block'], analysis }; + const hay = `${context.title} ${context.category}`.toLowerCase(); + const looksLikeClothing = flags.clothing || CLOTHING_KEYWORDS.some(word => hay.includes(word)) || !!analysis.garmentType; + if (!looksLikeClothing) return { allowed: false, reason: 'AI-Model-Fotos sind aktuell nur für Kleidung und Modeartikel vorgesehen.', context, variants, preset, mode, checks: ['not_clothing'], analysis }; + return { allowed: true, reason: 'Freigegeben', context, variants, preset, mode, checks: ['adult_only', 'fully_clothed', 'non_sexual'], analysis }; +} + + +function buildVariantPrompt({ analysis, context, preset, variantIndex, mode, customPrompt = '', preserveLogos = true }) { + const options = PRESET_VARIANTS[preset] || PRESET_VARIANTS.mixed; + const scene = options[variantIndex % options.length]; + const productDetails = [ + analysis.garmentType || context.category || 'Kleidungsstück', + analysis.color || context.color || null, + analysis.pattern ? `Muster: ${analysis.pattern}` : null, + analysis.material ? `Material: ${analysis.material}` : null, + analysis.fit ? `Schnitt/Fit: ${analysis.fit}` : null, + analysis.style ? `Stil: ${analysis.style}` : null, + context.brand ? `Marke: ${context.brand}` : null, + context.size ? `Größe: ${context.size}` : null, + context.condition ? `Zustand: ${context.condition}` : null, + ].filter(Boolean).join(', '); + const logoInstruction = preserveLogos + ? 'Do not alter, remove, stylize, mirror, replace, blur or invent any visible brand logo, chest print, text, embroidery, patch, label, badge or trademark on the garment. Preserve every visible logo and print exactly as shown in the source photo.' + : 'Keep visible branding and prints plausible and unchanged where possible.'; + const customInstruction = String(customPrompt || '').trim() ? `Additional custom seller instruction: ${String(customPrompt || '').trim().slice(0, 500)}.` : ''; + + if (mode === 'ghost_mannequin') { + return [ + 'Generate a photorealistic apparel photo for a second-hand marketplace.', + 'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not replace it with a generic garment.', + logoInstruction, + 'Show the clothing item as a clean ghost mannequin presentation with no visible real person.', + 'The garment must look naturally worn-in-form but with an invisible mannequin / hollow-neck apparel-photo style.', + 'No visible skin, no nudity, no erotic styling, no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + 'Use a neutral realistic marketplace background and keep the full garment easy to inspect.', + variantIndex === 0 ? 'Use a clear front view.' : variantIndex === 1 ? 'Use a 3/4 angle or detail-friendly secondary angle.' : 'Use an alternate clean catalog angle.', + customInstruction, + ].filter(Boolean).join(' '); + } + + if (mode === 'flatlay') { + return [ + 'Generate a photorealistic flat lay marketplace photo for a clothing item.', + 'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not invent another item.', + logoInstruction, + 'No person should be present. Arrange the garment neatly on a clean, realistic surface.', + 'The item must be the focus, fully visible, symmetrical where appropriate, and suitable for second-hand listing use.', + 'No erotic styling, no lingerie presentation, and no minor-related cues.', + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + variantIndex === 0 ? 'Use a clean straight-down hero shot.' : variantIndex === 1 ? 'Use a slightly styled flat lay with minimal accessories or folds only if tasteful.' : 'Use a third variation with another neat layout angle.', + customInstruction, + ].filter(Boolean).join(' '); + } + + return [ + 'Generate a photorealistic marketplace product image without any person.', + 'Use the uploaded product photo as the mandatory visual reference and preserve the item accurately.', + logoInstruction, + `The clothing item must closely match these details: ${productDetails}.`, + `Environment/look: ${scene}.`, + customInstruction, + ].filter(Boolean).join(' '); +} + +async function runOutputSafetyCheck(filePath) { + if (!process.env.OPENAI_API_KEY) return { safe: true, verdict: 'unchecked', notes: 'Keine zusätzliche API-Moderation konfiguriert.' }; + try { + const response = await getOpenAIClient().chat.completions.create({ + model: 'gpt-4o-mini', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: 'Prüfe Modebilder streng auf Nacktheit, Sexualisierung, Minderjährige oder unzulässige Inhalte. Antworte nur als JSON.' }, + { role: 'user', content: [ + { type: 'image_url', image_url: { url: `data:image/png;base64,${readFileSync(filePath).toString('base64')}`, detail: 'low' } }, + { type: 'text', text: 'Antworte mit JSON: {"safe": boolean, "verdict": "safe|blocked", "notes": string}. Block if nudity, lingerie pose, erotic styling or minor-looking person is visible.' }, + ] }, + ], + max_tokens: 180, + }); + const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null); + return parsed || { safe: true, verdict: 'safe', notes: 'Fallback safe.' }; + } catch { + return { safe: true, verdict: 'unchecked', notes: 'Moderation nicht verfügbar.' }; + } +} + +async function generateWithOpenAI(prompt, model) { + const response = await getOpenAIClient().images.generate({ model, prompt, size: '1024x1024', quality: 'medium' }); + return response.data?.[0]?.b64_json || null; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 90000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (error?.name === 'AbortError') throw new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`); + throw error; + } finally { + clearTimeout(timeout); + } +} + +function findImageValue(value) { + if (!value) return null; + if (typeof value === 'string') { + if (value.startsWith('data:image/')) return { type: 'base64', value: value.split(',')[1] || null }; + if (/^https?:\/\//i.test(value)) return { type: 'url', value }; + const dataMatch = value.match(/data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=]+)/i); + if (dataMatch) return { type: 'base64', value: dataMatch[1] }; + const markdownMatch = value.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i); + if (markdownMatch) return { type: 'url', value: markdownMatch[1] }; + return null; + } + if (Array.isArray(value)) { + for (const entry of value) { + const found = findImageValue(entry); + if (found) return found; + } + return null; + } + if (typeof value === 'object') { + if (typeof value.b64_json === 'string') return { type: 'base64', value: value.b64_json }; + const candidates = [ + value.url, + value.image_url?.url, + value.image_url, + value.source?.data, + value.data, + value.images, + value.content, + ]; + for (const candidate of candidates) { + const found = findImageValue(candidate); + if (found) return found; + } + } + return null; +} + +function extractOpenRouterImage(payload = {}) { + return findImageValue([ + payload?.data, + payload?.images, + payload?.choices?.[0]?.message?.images, + payload?.choices?.[0]?.message?.content, + payload?.output, + ]); +} + +async function downloadImageAsBase64(url) { + const response = await fetchWithTimeout(url, {}, 45000); + if (!response.ok) throw new Error(`Bild-Download fehlgeschlagen (${response.status})`); + return Buffer.from(await response.arrayBuffer()).toString('base64'); +} + +async function imageResultToBase64(result) { + if (!result?.value) return null; + return result.type === 'url' ? downloadImageAsBase64(result.value) : result.value; +} + +async function generateWithOpenRouterChat(prompt, candidate, sourceDataUrl = null) { + const content = []; + if (sourceDataUrl && candidate.supports_image_input) { + content.push({ type: 'image_url', image_url: { url: sourceDataUrl } }); + } + content.push({ type: 'text', text: prompt }); + + const baseBody = { + model: candidate.model, + modalities: ['image', 'text'], + messages: [{ role: 'user', content }], + stream: false, + }; + const requestBodies = [ + { ...baseBody, image_config: { aspect_ratio: '1:1' }, provider: { allow_fallbacks: true } }, + { ...baseBody, provider: { allow_fallbacks: true } }, + baseBody, + ]; + const errors = []; + + for (const body of requestBodies) { + const response = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify(body), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (response.ok) { + const result = extractOpenRouterImage(payload); + const base64 = await imageResultToBase64(result); + if (base64) return base64; + errors.push('Chat API antwortete erfolgreich, enthielt aber keine Bilddaten.'); + continue; + } + + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + errors.push(`Chat API ${response.status}: ${message}`); + if (![400, 404, 422].includes(response.status)) break; + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithOpenRouterImagesApi(prompt, candidate) { + const response = await fetchWithTimeout(OPENROUTER_IMAGES_URL, { + method: 'POST', + headers: getOpenRouterHeaders(), + body: JSON.stringify({ model: candidate.model, prompt, size: '1024x1024', n: 1 }), + }); + const text = await response.text(); + const payload = safeJsonParse(text, {}); + if (!response.ok) { + const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`; + throw new Error(`Images API ${response.status}: ${message}`); + } + const result = extractOpenRouterImage(payload); + return imageResultToBase64(result); +} + +async function generateWithOpenRouter(prompt, candidate, sourceDataUrl = null) { + const errors = []; + try { + const chatImage = await generateWithOpenRouterChat(prompt, candidate, sourceDataUrl); + if (chatImage) return chatImage; + errors.push('Chat API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Chat API fehlgeschlagen.'); + } + + try { + const apiImage = await generateWithOpenRouterImagesApi(prompt, candidate); + if (apiImage) return apiImage; + errors.push('Images API lieferte keine Bilddaten.'); + } catch (error) { + errors.push(error.message || 'Images API fehlgeschlagen.'); + } + + throw new Error(errors.join(' | ')); +} + +async function generateWithProviderCandidate(prompt, candidate, sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) { + let result = {}; + if (candidate.provider === 'openrouter') { + result.base64 = await generateWithOpenRouter(prompt, candidate, sourceDataUrl); + result.reference_used = !!sourceDataUrl && candidate.supports_image_input !== false; + } else if (candidate.provider === 'local') { + result = await generateWithLocalFlux({ prompt, settings, mode, variantIndex, sourceDataUrl, onProgress }); + } else { + result.base64 = await generateWithOpenAI(prompt, candidate.model); + result.reference_used = false; + } + if (!result.base64) throw new Error(`${candidate.label || candidate.model} lieferte keine Bilddaten zurück.`); + return result; +} + +async function generateWithProviderFallback(prompt, candidates = [], sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) { + const attempts = []; + for (const candidate of candidates) { + try { + if (typeof onProgress === 'function') onProgress(4, `${candidate.label || candidate.model} wird vorbereitet …`); + const result = await generateWithProviderCandidate(prompt, candidate, sourceDataUrl, settings, mode, variantIndex, onProgress); + return { ...result, candidate, attempts }; + } catch (error) { + attempts.push({ + provider: candidate.provider, + model: candidate.model, + label: candidate.label, + error: error.message || 'Unbekannter Fehler', + }); + } + } + const summary = attempts.map(item => `${item.label || item.model}: ${item.error}`).join(' | '); + const error = new Error(summary || 'Alle Bildmodelle sind fehlgeschlagen.'); + error.attempts = attempts; + throw error; +} + + +export async function generateAiModelPhotos(input = {}, onProgress = null) { + const report = (progress, message, meta = {}) => { + if (typeof onProgress === 'function') onProgress(progress, message, meta); + }; + + report(5, 'Produktfotos und Sicherheitsregeln werden geprüft …'); + const precheck = await precheckAiModelPhotos(input, input.settings || {}); + if (!precheck.allowed) { + const error = new Error(precheck.reason || 'AI-Model-Fotos wurden blockiert.'); + error.code = 'AI_MODEL_PRECHECK_FAILED'; + error.precheck = precheck; + throw error; + } + + + report(12, 'Passender Bildprovider wird ausgewählt …'); + let providerCandidates = await resolveImageProviders(input.settings || {}); + const context = precheck.context; + const analysis = precheck.analysis || heuristicAnalysis(context); + const variants = precheck.variants; + const preset = precheck.preset; + const mode = precheck.mode || 'flatlay'; + const created = []; + const useSourceReference = String(input.settings?.ai_model_photo_use_source_reference ?? '1') !== '0'; + const preserveLogos = String(input.preserve_logos ?? input.settings?.ai_model_photo_preserve_logos ?? '1') !== '0'; + const customPrompt = String(input.custom_prompt || '').trim(); + const sourceDataUrl = useSourceReference && input.photos?.[0] ? fileToDataUrl(input.photos[0]) : null; + let lastProviderInfo = providerCandidates[0] || null; + const providerAttempts = []; + + for (let index = 0; index < variants; index += 1) { + const variantStart = 16 + Math.round((index / variants) * 72); + const variantEnd = 16 + Math.round(((index + 1) / variants) * 72); + const prompt = buildVariantPrompt({ analysis, context, preset, variantIndex: index, mode, customPrompt, preserveLogos }); + report(variantStart, `Variante ${index + 1} von ${variants} wird vorbereitet …`); + const generated = await generateWithProviderFallback( + prompt, + providerCandidates, + sourceDataUrl, + input.settings || {}, + mode, + index, + (providerProgress, message) => { + const scaled = variantStart + Math.round((Math.max(0, Math.min(100, Number(providerProgress || 0))) / 100) * Math.max(1, variantEnd - variantStart - 5)); + report(Math.min(91, scaled), `Variante ${index + 1}/${variants}: ${message}`); + }, + ); + const base64 = generated.base64; + const providerInfo = generated.candidate; + lastProviderInfo = providerInfo; + providerAttempts.push(...(generated.attempts || [])); + providerCandidates = [providerInfo, ...providerCandidates.filter(candidate => !(candidate.provider === providerInfo.provider && candidate.model === providerInfo.model))]; + + report(Math.max(variantStart, variantEnd - 4), `Variante ${index + 1}/${variants}: Sicherheitsprüfung läuft …`); + const filename = `ai-models/${randomUUID()}.png`; + const filePath = join(uploadsDir, filename); + writeFileSync(filePath, Buffer.from(base64, 'base64')); + + const moderation = await runOutputSafetyCheck(filePath); + if (!moderation.safe) continue; + created.push({ + filename, + prompt, + moderation, + variant_index: index + 1, + preset, + source_photo: input.photos?.[0] || null, + public_url: `/uploads/${filename}`, + provider: providerInfo.provider, + provider_label: providerInfo.label, + provider_model: providerInfo.model, + provider_free: providerInfo.free, + mode, + reference_used: generated.reference_used ?? useSourceReference, + }); + report(variantEnd, `Variante ${index + 1} von ${variants} ist fertig.`); + } + + if (!created.length) throw new Error('Es konnten keine sicheren AI-Model-Fotos erzeugt werden.'); + report(92, 'AI-Bilder werden für das Listing vorbereitet …'); + + return { + precheck, + provider: lastProviderInfo, + provider_attempts: providerAttempts, + mode, + reference_used: useSourceReference, + prompt_summary: `${analysis.garmentType || context.category || 'Kleidungsstück'} · ${mode} · ${preset} · ${created.length} Variante(n)${useSourceReference ? ' · mit Produktreferenz' : ''}${preserveLogos ? ' · Logos geschützt' : ''}${customPrompt ? ' · mit Benutzerprompt' : ''}`, + photos: created, + }; +} diff --git a/lib/ai-openai.mjs b/lib/ai-openai.mjs new file mode 100644 index 0000000..d32da1c --- /dev/null +++ b/lib/ai-openai.mjs @@ -0,0 +1,45 @@ +import OpenAI from 'openai'; + +let client; + +function getClient() { + if (!client) client = new OpenAI(); + return client; +} + +export async function generate(images, systemPrompt) { + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}`, detail: 'high' }, + }); + } + content.push({ type: 'text', text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.' }); + + const response = await getClient().chat.completions.create({ + model: 'gpt-4o', + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }); + + const text = response.choices[0].message.content; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('AI returned no valid JSON'); + return JSON.parse(jsonMatch[0]); +} + +export async function generateText(systemPrompt, userPrompt) { + const response = await getClient().chat.completions.create({ + model: 'gpt-4o', + max_tokens: 1400, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }); + return String(response.choices?.[0]?.message?.content || '').trim(); +} diff --git a/lib/ai-openrouter.mjs b/lib/ai-openrouter.mjs new file mode 100644 index 0000000..54a89c6 --- /dev/null +++ b/lib/ai-openrouter.mjs @@ -0,0 +1,152 @@ +const OPENROUTER_BASE = 'https://openrouter.ai/api/v1'; + +export const MODELS = [ + { id: 'google/gemini-3.1-flash-lite-image-20260630', name: 'Gemini 3.1 Flash Lite (~0)', free: false, priority: 1 }, + { id: 'google/gemini-3.1-flash-image-20260528', name: 'Gemini 3.1 Flash Image (~0)', free: false, priority: 2 }, + { id: 'google/gemini-3-pro-image-20260528', name: 'Gemini 3 Pro Image', free: false, priority: 3 }, + { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', name: 'Nemotron 3 Nano Omni (free)', free: true, priority: 4 }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)', free: true, priority: 5 }, + { id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)', free: true, priority: 6 }, + { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 (free)', free: true, priority: 7 }, + { id: 'qwen/qwen3-235b-a22b:free', name: 'Qwen 3 235B (free)', free: true, priority: 8 }, +]; + +function extractJSON(text) { + if (!text) return null; + // Try direct parse first + try { return JSON.parse(text); } catch {} + // Find outermost { ... } + let depth = 0, start = -1; + for (let i = 0; i < text.length; i++) { + if (text[i] === '{') { if (depth === 0) start = i; depth++; } + else if (text[i] === '}') { depth--; if (depth === 0 && start >= 0) { + try { return JSON.parse(text.substring(start, i + 1)); } catch {} + }} + } + // Try fixing common issues: trailing commas, unescaped newlines + const match = text.match(/\{[\s\S]*\}/); + if (match) { + let cleaned = match[0] + .replace(/,\s*([}\]])/g, '$1') // trailing commas + .replace(/[\x00-\x1f]/g, m => // control chars in strings + m === '\n' ? '\\n' : m === '\r' ? '\\r' : m === '\t' ? '\\t' : '' + ); + try { return JSON.parse(cleaned); } catch {} + } + return null; +} + +async function callModel(modelId, images, systemPrompt) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY nicht gesetzt'); + + const content = []; + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { url: `data:${img.mediaType};base64,${img.base64}` }, + }); + } + content.push({ + type: 'text', + text: 'Analysiere die Fotos und erstelle das Listing gemäß den Anweisungen. Antworte ausschließlich mit validem JSON.', + }); + + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'http://localhost:8124', + 'X-Title': 'Vendoo', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 1500, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content }, + ], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${res.status}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content; + if (!text) throw new Error('Leere Antwort'); + + const parsed = extractJSON(text); + if (!parsed) throw new Error('Kein valides JSON in Antwort'); + return parsed; +} + + +async function callTextModel(modelId, systemPrompt, userPrompt) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error('OPENROUTER_API_KEY nicht gesetzt'); + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'http://localhost:8124', + 'X-Title': 'Vendoo', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 1400, + messages: [ + { role: 'system', content: String(systemPrompt || '') }, + { role: 'user', content: String(userPrompt || '') }, + ], + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${res.status}`); + } + const data = await res.json(); + return String(data.choices?.[0]?.message?.content || '').trim(); +} + +export async function generateText(systemPrompt, userPrompt, model) { + if (model) return callTextModel(model, systemPrompt, userPrompt); + const errors = []; + for (const entry of [...MODELS].sort((a, b) => a.priority - b.priority)) { + try { return await callTextModel(entry.id, systemPrompt, userPrompt); } + catch (error) { + errors.push(`${entry.name}: ${error.message}`); + if (/API_KEY|nicht gesetzt/i.test(error.message)) break; + } + } + throw new Error(`Kein OpenRouter-Modell konnte den Prompt verbessern: ${errors.join(' | ')}`); +} + +export async function generate(images, systemPrompt, model) { + if (model) { + return callModel(model, images, systemPrompt); + } + + const ordered = [...MODELS].sort((a, b) => a.priority - b.priority); + const errors = []; + + for (const m of ordered) { + try { + console.log(`OpenRouter: Versuche ${m.name}...`); + const result = await callModel(m.id, images, systemPrompt); + console.log(`OpenRouter: Erfolg mit ${m.name}`); + return result; + } catch (err) { + const msg = err.message; + console.log(`OpenRouter: ${m.name} fehlgeschlagen: ${msg}`); + errors.push(`${m.name}: ${msg}`); + if (msg.includes('API_KEY') || msg.includes('nicht gesetzt')) break; + } + } + + throw new Error(`Alle Modelle fehlgeschlagen:\n${errors.join('\n')}`); +} diff --git a/lib/ai-router.mjs b/lib/ai-router.mjs new file mode 100644 index 0000000..8a25007 --- /dev/null +++ b/lib/ai-router.mjs @@ -0,0 +1,40 @@ +import * as claude from './ai-claude.mjs'; +import * as openai from './ai-openai.mjs'; +import * as openrouter from './ai-openrouter.mjs'; +import * as local from './ai-local.mjs'; + +const providers = { claude, openai, openrouter, local }; + +export async function generate(provider, images, systemPrompt, model) { + const ai = providers[provider]; + if (!ai) throw new Error(`Unknown AI provider: ${provider}`); + return ai.generate(images, systemPrompt, model); +} + +export async function generateText(provider, systemPrompt, userPrompt, model) { + const ai = providers[provider]; + if (!ai) throw new Error(`Unknown AI provider: ${provider}`); + if (typeof ai.generateText !== 'function') throw new Error(`Textgenerierung wird von ${provider} nicht unterstützt.`); + return ai.generateText(systemPrompt, userPrompt, model); +} + +export function getProviders() { + return [ + { id: 'claude', name: 'Claude', hasModels: false, needsKey: 'ANTHROPIC_API_KEY' }, + { id: 'openai', name: 'OpenAI', hasModels: false, needsKey: 'OPENAI_API_KEY' }, + { + id: 'openrouter', + name: 'OpenRouter', + hasModels: true, + models: openrouter.MODELS, + needsKey: 'OPENROUTER_API_KEY', + }, + { + id: 'local', + name: 'Lokal (Ollama)', + hasModels: true, + models: local.MODELS, + needsKey: null, + }, + ]; +} diff --git a/lib/archive.mjs b/lib/archive.mjs new file mode 100644 index 0000000..d540d74 --- /dev/null +++ b/lib/archive.mjs @@ -0,0 +1,78 @@ +import { inflateRawSync } from 'zlib'; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; + +function normalizeEntryName(name) { + const normalized = String(name || '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || normalized.endsWith('/')) return normalized; + if (normalized.includes('\0') || normalized.split('/').some(part => part === '..')) { + throw new Error(`Unsicherer ZIP-Pfad: ${name}`); + } + return normalized; +} + +function findEndOfCentralDirectory(buffer) { + const min = Math.max(0, buffer.length - 0xffff - 22); + for (let offset = buffer.length - 22; offset >= min; offset--) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset; + } + throw new Error('ZIP-Endverzeichnis wurde nicht gefunden.'); +} + +export function readZipEntries(buffer, { maxEntries = 10000, maxUncompressedBytes = 8 * 1024 * 1024 * 1024 } = {}) { + if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer || []); + const eocd = findEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocd + 10); + const centralSize = buffer.readUInt32LE(eocd + 12); + const centralOffset = buffer.readUInt32LE(eocd + 16); + if (entryCount > maxEntries) throw new Error(`ZIP enthält zu viele Dateien (${entryCount}).`); + if (centralOffset + centralSize > buffer.length) throw new Error('ZIP-Zentralverzeichnis ist beschädigt.'); + + const entries = []; + let offset = centralOffset; + let totalUncompressed = 0; + for (let index = 0; index < entryCount; index++) { + if (buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) throw new Error('ZIP-Zentralverzeichnis ist ungültig.'); + const flags = buffer.readUInt16LE(offset + 8); + const method = buffer.readUInt16LE(offset + 10); + const crc = buffer.readUInt32LE(offset + 16); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localOffset = buffer.readUInt32LE(offset + 42); + const nameStart = offset + 46; + const name = normalizeEntryName(buffer.subarray(nameStart, nameStart + nameLength).toString((flags & 0x0800) ? 'utf8' : 'utf8')); + totalUncompressed += uncompressedSize; + if (totalUncompressed > maxUncompressedBytes) throw new Error('ZIP ist nach dem Entpacken zu groß.'); + entries.push({ name, method, crc, compressedSize, uncompressedSize, localOffset, directory: name.endsWith('/') }); + offset = nameStart + nameLength + extraLength + commentLength; + } + return entries; +} + +export function extractZipEntry(buffer, entry) { + if (entry.directory) return Buffer.alloc(0); + const offset = entry.localOffset; + if (buffer.readUInt32LE(offset) !== LOCAL_SIGNATURE) throw new Error(`Lokaler ZIP-Header fehlt: ${entry.name}`); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + nameLength + extraLength; + const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize); + let data; + if (entry.method === 0) data = Buffer.from(compressed); + else if (entry.method === 8) data = inflateRawSync(compressed); + else throw new Error(`Nicht unterstützte ZIP-Kompression (${entry.method}) für ${entry.name}.`); + if (data.length !== entry.uncompressedSize) throw new Error(`ZIP-Größe stimmt nicht: ${entry.name}`); + return data; +} + +export function readZip(buffer, options) { + const entries = readZipEntries(buffer, options); + const files = new Map(); + for (const entry of entries) if (!entry.directory) files.set(entry.name, extractZipEntry(buffer, entry)); + return { entries, files }; +} diff --git a/lib/auth.mjs b/lib/auth.mjs new file mode 100644 index 0000000..3d8a951 --- /dev/null +++ b/lib/auth.mjs @@ -0,0 +1,377 @@ +import { db } from './db.mjs'; +import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +// --- Schema --- +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'editor', + active INTEGER NOT NULL DEFAULT 1, + avatar_color TEXT, + last_active_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + invited_by INTEGER, + FOREIGN KEY (invited_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + ip TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + type TEXT NOT NULL DEFAULT 'login', + role TEXT DEFAULT 'editor', + used INTEGER NOT NULL DEFAULT 0, + expires_at TEXT NOT NULL, + created_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (created_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + ip TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS login_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + ip TEXT, + user_agent TEXT, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +// Migration: add password_hash column if missing +try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN locked_until TEXT"); } catch {} +try { db.exec("ALTER TABLE users ADD COLUMN password_changed_at TEXT"); } catch {} +try { db.exec("ALTER TABLE sessions ADD COLUMN last_seen_at TEXT"); } catch {} +try { db.exec("ALTER TABLE sessions ADD COLUMN revoked_at TEXT"); } catch {} +try { db.exec("UPDATE sessions SET last_seen_at = COALESCE(last_seen_at, created_at)"); } catch {} + +const AVATAR_COLORS = [ + '#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b', + '#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c', +]; + +const SESSION_DURATION_MS = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000; +const SESSION_IDLE_MS = Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)) * 60 * 1000; +const SESSION_TOUCH_INTERVAL_MS = 60 * 1000; +const LOGIN_TOKEN_EXPIRY_MIN = 15; +const INVITE_TOKEN_EXPIRY_HOURS = 48; + +function hashToken(token) { + return createHash('sha256').update(token).digest('hex'); +} + +function generateToken() { + return randomBytes(32).toString('hex'); +} + +function randomAvatarColor() { + return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; +} + +// --- Password Hashing (scrypt) --- +export function hashPassword(password) { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password, storedHash) { + if (!storedHash) return false; + const [salt, hash] = storedHash.split(':'); + if (!salt || !hash) return false; + const hashBuf = Buffer.from(hash, 'hex'); + const supplied = scryptSync(password, salt, 64); + return timingSafeEqual(hashBuf, supplied); +} + +// --- Users --- +export function createUser(email, name, role = 'editor', invitedBy = null, password = null) { + role = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor'; + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) throw new Error('User existiert bereits'); + const color = randomAvatarColor(); + const pwHash = password ? hashPassword(password) : null; + const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash); + return getUser(r.lastInsertRowid); +} + +export function setPassword(userId, password) { + const pwHash = hashPassword(password); + db.prepare("UPDATE users SET password_hash = ?, password_changed_at = datetime('now'), failed_login_count = 0, locked_until = NULL WHERE id = ?").run(pwHash, userId); + destroyUserSessions(userId); +} + +export function validatePasswordStrength(password) { + const value = String(password || ''); + const problems = []; + if (value.length < 10) problems.push('mindestens 10 Zeichen'); + if (!/[a-z]/.test(value)) problems.push('einen Kleinbuchstaben'); + if (!/[A-Z]/.test(value)) problems.push('einen Großbuchstaben'); + if (!/\d/.test(value)) problems.push('eine Zahl'); + if (!/[^A-Za-z0-9]/.test(value)) problems.push('ein Sonderzeichen'); + return { valid: problems.length === 0, problems }; +} + +export function registerFailedLogin(userId, maxAttempts = 8, lockMinutes = 15) { + if (!userId) return null; + const user = getUser(userId); + if (!user) return null; + const count = Number(user.failed_login_count || 0) + 1; + if (count >= maxAttempts) { + const lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000).toISOString(); + db.prepare('UPDATE users SET failed_login_count = 0, locked_until = ? WHERE id = ?').run(lockedUntil, userId); + destroyUserSessions(userId); + return { locked: true, lockedUntil }; + } + db.prepare('UPDATE users SET failed_login_count = ? WHERE id = ?').run(count, userId); + return { locked: false, remaining: Math.max(0, maxAttempts - count) }; +} + +export function clearFailedLogins(userId) { + if (!userId) return; + db.prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?').run(userId); +} + +export function isUserLocked(user) { + if (!user?.locked_until) return false; + const until = Date.parse(user.locked_until); + if (!Number.isFinite(until) || until <= Date.now()) { + clearFailedLogins(user.id); + return false; + } + return true; +} + +export function getUser(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); +} + +export function getUserByEmail(email) { + return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email); +} + +export function getUsers() { + return db.prepare("SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, locked_until, failed_login_count, CASE WHEN password_hash IS NOT NULL AND password_hash != '' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at").all(); +} + +export function updateUser(id, data) { + const allowed = ['name', 'role', 'active', 'avatar_color']; + const fields = [], values = []; + for (const [k, rawValue] of Object.entries(data)) { + if (!allowed.includes(k) || rawValue === undefined) continue; + const v = k === 'role' ? (['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(rawValue) ? rawValue : 'viewer') : rawValue; + fields.push(`${k} = ?`); values.push(v); + } + if (!fields.length) return getUsers().find(user => Number(user.id) === Number(id)) || null; + values.push(id); + db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getUsers().find(user => Number(user.id) === Number(id)) || null; +} + +export function deleteUser(id) { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); +} + +export function touchUserActivity(userId) { + db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId); +} + +export function getUserCount() { + return db.prepare('SELECT COUNT(*) as count FROM users').get().count; +} + +// --- Sessions --- +export function createSession(userId, ip, userAgent) { + const token = generateToken(); + const hash = hashToken(token); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS).toISOString(); + const lastSeenAt = now.toISOString(); + db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)').run(userId, hash, ip, userAgent || '', expiresAt, lastSeenAt); + return { token, expiresAt }; +} + +export function validateSession(token) { + if (!token) return null; + const hash = hashToken(token); + const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color, u.locked_until FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now')").get(hash); + if (!session || !session.active || isUserLocked(session)) return null; + const lastSeen = Date.parse(session.last_seen_at || session.created_at || 0); + if (Number.isFinite(lastSeen) && Date.now() - lastSeen > SESSION_IDLE_MS) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(session.id); + return null; + } + if (!Number.isFinite(lastSeen) || Date.now() - lastSeen >= SESSION_TOUCH_INTERVAL_MS) { + const now = new Date().toISOString(); + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run(now, session.id); + session.last_seen_at = now; + touchUserActivity(session.user_id); + } + return session; +} + +export function destroySession(token) { + const hash = hashToken(token); + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE token_hash = ?").run(hash); +} + +export function destroySessionById(id) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(id); +} + +export function destroyUserSessions(userId) { + db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL").run(userId); +} + +export function getUserSessions(userId) { + return db.prepare("SELECT id, ip, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId); +} + +export function getAllActiveSessions() { + return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.revoked_at IS NULL AND s.expires_at > datetime('now') ORDER BY s.created_at DESC").all(); +} + +// clean expired sessions periodically +export function cleanExpiredSessions() { + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now') OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', '-7 days'))").run(); +} + +// --- Auth Tokens (Magic Links) --- +export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) { + const token = generateToken(); + const hash = hashToken(token); + const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN; + const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString(); + + // invalidate previous unused tokens of same type for same email + db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type); + + db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy); + return { token, expiresAt }; +} + +export function validateAuthToken(token) { + const hash = hashToken(token); + const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash); + if (!row) return null; + db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id); + return row; +} + +// --- Rate Limiting --- +const rateLimits = new Map(); + +export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) { + const now = Date.now(); + const entry = rateLimits.get(key); + if (!entry) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + if (now - entry.firstAttempt > windowMs) { + rateLimits.set(key, { count: 1, firstAttempt: now }); + return { allowed: true, remaining: maxAttempts - 1 }; + } + entry.count++; + if (entry.count > maxAttempts) { + const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000); + return { allowed: false, remaining: 0, retryAfter }; + } + return { allowed: true, remaining: maxAttempts - entry.count }; +} + +// --- CSRF --- +export function generateCsrfToken() { + return randomBytes(24).toString('hex'); +} + +// --- Audit Log --- +export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) { + db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip); +} + +export function getAuditLog(limit = 100, offset = 0) { + return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset); +} + +export function searchAuditLog({ limit = 100, offset = 0, userId = null, action = '', from = '', to = '', query = '' } = {}) { + const clauses = []; + const values = []; + if (userId) { clauses.push('user_id = ?'); values.push(Number(userId)); } + if (action) { clauses.push('action LIKE ?'); values.push(`%${String(action).slice(0, 80)}%`); } + if (from) { clauses.push('created_at >= ?'); values.push(String(from).slice(0, 30)); } + if (to) { clauses.push('created_at <= ?'); values.push(String(to).slice(0, 30)); } + if (query) { + const q = `%${String(query).slice(0, 120)}%`; + clauses.push('(user_email LIKE ? OR action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR details LIKE ? OR ip LIKE ?)'); + values.push(q, q, q, q, q, q); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 100)); + const safeOffset = Math.max(0, Number(offset) || 0); + const rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...values, safeLimit, safeOffset); + const total = db.prepare(`SELECT COUNT(*) AS count FROM audit_log ${where}`).get(...values)?.count || 0; + return { rows, total, limit: safeLimit, offset: safeOffset }; +} + +export function getAuditLogForUser(userId, limit = 50) { + return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +// --- Login History --- +export function recordLogin(userId, ip, userAgent, success = true) { + db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0); +} + +export function getLoginHistory(userId, limit = 20) { + return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit); +} + +export function getRecentFailedLogins(email, windowMinutes = 15) { + const user = getUserByEmail(email); + if (!user) return 0; + const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`); + return r.count; +} + +// --- Setup: ensure at least one admin exists --- +export function ensureAdminExists() { + const count = getUserCount(); + return count === 0; +} + +export function isSetupMode() { + return getUserCount() === 0; +} + +// Clean expired sessions every 10 minutes +setInterval(cleanExpiredSessions, 10 * 60 * 1000); diff --git a/lib/categories.mjs b/lib/categories.mjs new file mode 100644 index 0000000..708fc0c --- /dev/null +++ b/lib/categories.mjs @@ -0,0 +1,359 @@ +const CATEGORIES = { + vinted: [ + { id: 5, name: 'Damen', children: [ + { id: 1904, name: 'Kleidung', children: [ + { id: 1, name: 'Tops & T-Shirts' }, { id: 2, name: 'Pullover & Strickjacken' }, + { id: 3, name: 'Blusen & Hemden' }, { id: 4, name: 'Kleider' }, { id: 6, name: 'Röcke' }, + { id: 7, name: 'Hosen & Leggings' }, { id: 8, name: 'Jeans' }, { id: 9, name: 'Jacken & Mäntel' }, + { id: 10, name: 'Anzüge & Blazer' }, { id: 11, name: 'Sportbekleidung' }, { id: 12, name: 'Bademode' }, + { id: 13, name: 'Unterwäsche & Nachtwäsche' }, { id: 15, name: 'Overalls & Jumpsuits' }, + { id: 1226, name: 'Shorts' }, + ]}, + { id: 1905, name: 'Schuhe', children: [ + { id: 17, name: 'Sneaker' }, { id: 18, name: 'Stiefel & Stiefeletten' }, + { id: 19, name: 'Pumps & High Heels' }, { id: 20, name: 'Sandalen' }, { id: 21, name: 'Ballerinas' }, + { id: 23, name: 'Sportschuhe' }, + ]}, + { id: 1906, name: 'Taschen', children: [ + { id: 25, name: 'Handtaschen' }, { id: 26, name: 'Umhängetaschen' }, { id: 27, name: 'Rucksäcke' }, + { id: 28, name: 'Clutches' }, { id: 29, name: 'Geldbörsen & Etuis' }, + ]}, + { id: 1907, name: 'Accessoires', children: [ + { id: 31, name: 'Schmuck' }, { id: 32, name: 'Uhren' }, { id: 33, name: 'Sonnenbrillen' }, + { id: 34, name: 'Schals & Tücher' }, { id: 35, name: 'Gürtel' }, { id: 36, name: 'Mützen & Hüte' }, + ]}, + { id: 1919, name: 'Pflege & Kosmetik', children: [ + { id: 38, name: 'Makeup' }, { id: 39, name: 'Hautpflege' }, { id: 41, name: 'Parfum' }, + ]}, + ]}, + { id: 1206, name: 'Herren', children: [ + { id: 1908, name: 'Kleidung', children: [ + { id: 50, name: 'T-Shirts & Poloshirts' }, { id: 51, name: 'Pullover & Hoodies' }, + { id: 52, name: 'Hemden' }, { id: 53, name: 'Hosen' }, { id: 54, name: 'Jeans' }, + { id: 55, name: 'Shorts' }, { id: 56, name: 'Jacken & Mäntel' }, { id: 57, name: 'Anzüge & Sakkos' }, + { id: 58, name: 'Sportbekleidung' }, { id: 60, name: 'Unterwäsche & Nachtwäsche' }, + ]}, + { id: 1909, name: 'Schuhe', children: [ + { id: 62, name: 'Sneaker' }, { id: 63, name: 'Stiefel & Stiefeletten' }, + { id: 64, name: 'Business-Schuhe' }, { id: 65, name: 'Sandalen' }, { id: 66, name: 'Sportschuhe' }, + ]}, + { id: 1910, name: 'Taschen', children: [ + { id: 68, name: 'Rucksäcke' }, { id: 69, name: 'Umhängetaschen' }, { id: 71, name: 'Geldbörsen' }, + ]}, + { id: 1911, name: 'Accessoires', children: [ + { id: 72, name: 'Uhren' }, { id: 73, name: 'Sonnenbrillen' }, { id: 74, name: 'Krawatten & Fliegen' }, + { id: 75, name: 'Gürtel' }, { id: 76, name: 'Mützen & Hüte' }, { id: 78, name: 'Schmuck' }, + ]}, + ]}, + { id: 1207, name: 'Kinder', children: [ + { id: 1912, name: 'Mädchen-Kleidung', children: [ + { id: 100, name: 'Tops & T-Shirts' }, { id: 102, name: 'Kleider & Röcke' }, + { id: 103, name: 'Hosen & Jeans' }, { id: 104, name: 'Jacken & Mäntel' }, + ]}, + { id: 1913, name: 'Jungen-Kleidung', children: [ + { id: 110, name: 'Tops & T-Shirts' }, { id: 111, name: 'Pullover & Hoodies' }, + { id: 112, name: 'Hosen & Jeans' }, { id: 113, name: 'Jacken & Mäntel' }, + ]}, + { id: 1915, name: 'Kinderschuhe', children: [ + { id: 130, name: 'Sneaker' }, { id: 131, name: 'Stiefel' }, { id: 132, name: 'Sandalen' }, + ]}, + { id: 1916, name: 'Spielzeug', children: [ + { id: 140, name: 'Puppen' }, { id: 142, name: 'Bausteine' }, { id: 143, name: 'Kuscheltiere' }, + { id: 144, name: 'Brettspiele & Puzzles' }, + ]}, + ]}, + { id: 1208, name: 'Haus & Wohnen', children: [ + { id: 1917, name: 'Dekoration', children: [ + { id: 200, name: 'Bilder & Poster' }, { id: 201, name: 'Kerzen' }, { id: 202, name: 'Vasen' }, + ]}, + { id: 245, name: 'Küche', children: [ + { id: 210, name: 'Geschirr' }, { id: 211, name: 'Gläser & Tassen' }, { id: 213, name: 'Küchengeräte' }, + ]}, + { id: 246, name: 'Textilien', children: [ + { id: 220, name: 'Bettwäsche' }, { id: 222, name: 'Kissen & Decken' }, { id: 223, name: 'Teppiche' }, + ]}, + { id: 247, name: 'Möbel', children: [ + { id: 230, name: 'Regale' }, { id: 231, name: 'Tische' }, { id: 232, name: 'Stühle' }, + ]}, + ]}, + { id: 1209, name: 'Unterhaltung', children: [ + { id: 249, name: 'Bücher', children: [ + { id: 300, name: 'Romane' }, { id: 301, name: 'Sachbücher' }, { id: 303, name: 'Comics & Manga' }, + ]}, + { id: 251, name: 'Spiele & Konsolen', children: [ + { id: 320, name: 'Videospiele' }, { id: 321, name: 'Konsolen' }, { id: 323, name: 'Brettspiele' }, + ]}, + { id: 252, name: 'Sport', children: [ + { id: 330, name: 'Fitnessgeräte' }, { id: 331, name: 'Fahrräder' }, { id: 332, name: 'Camping' }, + ]}, + ]}, + { id: 1210, name: 'Elektronik', children: [ + { id: 253, name: 'Handys', children: [ + { id: 400, name: 'iPhones' }, { id: 401, name: 'Samsung' }, { id: 402, name: 'Sonstige Smartphones' }, + { id: 403, name: 'Handyhüllen & Zubehör' }, + ]}, + { id: 254, name: 'Computer & Tablets', children: [ + { id: 410, name: 'Laptops' }, { id: 411, name: 'Tablets' }, { id: 414, name: 'NAS & Server' }, + { id: 417, name: 'PC-Zubehör' }, + ]}, + { id: 255, name: 'Foto & Kamera', children: [ + { id: 420, name: 'Digitalkameras' }, { id: 422, name: 'Kamerazubehör' }, + ]}, + { id: 256, name: 'Audio & HiFi', children: [ + { id: 430, name: 'Kopfhörer' }, { id: 431, name: 'Lautsprecher' }, + ]}, + ]}, + ], + + 'ebay-ka': [ + { id: 'mode', name: 'Mode & Beauty', children: [ + { id: 'damen', name: 'Damenmode', children: [ + { id: 'dk1', name: 'Oberteile & T-Shirts' }, { id: 'dk2', name: 'Kleider' }, + { id: 'dk3', name: 'Hosen & Jeans' }, { id: 'dk4', name: 'Jacken & Mäntel' }, + { id: 'dk5', name: 'Pullover & Strickwaren' }, { id: 'dk6', name: 'Schuhe' }, + { id: 'dk7', name: 'Taschen' }, { id: 'dk8', name: 'Accessoires' }, + ]}, + { id: 'herren', name: 'Herrenmode', children: [ + { id: 'hk1', name: 'T-Shirts & Hemden' }, { id: 'hk2', name: 'Hosen & Jeans' }, + { id: 'hk3', name: 'Jacken & Mäntel' }, { id: 'hk4', name: 'Pullover & Hoodies' }, + { id: 'hk5', name: 'Schuhe' }, { id: 'hk6', name: 'Accessoires' }, + ]}, + { id: 'beauty', name: 'Beauty & Gesundheit', children: [ + { id: 'bk1', name: 'Parfum' }, { id: 'bk2', name: 'Hautpflege' }, { id: 'bk3', name: 'Makeup' }, + ]}, + ]}, + { id: 'elektronik', name: 'Elektronik', children: [ + { id: 'handy', name: 'Handy & Telefon', children: [ + { id: 'ek1', name: 'Smartphones' }, { id: 'ek2', name: 'Zubehör' }, + ]}, + { id: 'pc', name: 'PC & Zubehör', children: [ + { id: 'ek3', name: 'Notebooks & Laptops' }, { id: 'ek4', name: 'PCs & Desktops' }, + { id: 'ek5', name: 'Monitore' }, { id: 'ek6', name: 'Netzwerk & Server' }, + { id: 'ek7', name: 'Drucker & Scanner' }, { id: 'ek8', name: 'Zubehör' }, + ]}, + { id: 'audio', name: 'Audio & HiFi', children: [ + { id: 'ek9', name: 'Kopfhörer' }, { id: 'ek10', name: 'Lautsprecher' }, { id: 'ek11', name: 'HiFi-Anlagen' }, + ]}, + { id: 'foto', name: 'Foto & Kamera', children: [ + { id: 'ek12', name: 'Digitalkameras' }, { id: 'ek13', name: 'Objektive' }, { id: 'ek14', name: 'Zubehör' }, + ]}, + { id: 'tv', name: 'TV & Video', children: [ + { id: 'ek15', name: 'Fernseher' }, { id: 'ek16', name: 'Streaming' }, + ]}, + { id: 'konsolen', name: 'Konsolen & Spiele', children: [ + { id: 'ek17', name: 'Konsolen' }, { id: 'ek18', name: 'Spiele' }, { id: 'ek19', name: 'Zubehör' }, + ]}, + ]}, + { id: 'haus', name: 'Haus & Garten', children: [ + { id: 'moebel', name: 'Möbel', children: [ + { id: 'hk1', name: 'Tische' }, { id: 'hk2', name: 'Stühle' }, { id: 'hk3', name: 'Regale & Schränke' }, + ]}, + { id: 'deko', name: 'Dekoration', children: [ + { id: 'hk4', name: 'Bilder & Poster' }, { id: 'hk5', name: 'Vasen & Kerzen' }, + ]}, + { id: 'kueche', name: 'Küche', children: [ + { id: 'hk6', name: 'Geschirr' }, { id: 'hk7', name: 'Geräte' }, + ]}, + { id: 'garten', name: 'Garten', children: [ + { id: 'hk8', name: 'Gartenmöbel' }, { id: 'hk9', name: 'Geräte & Werkzeug' }, + ]}, + ]}, + { id: 'kinder', name: 'Familie, Kind & Baby', children: [ + { id: 'kkleidung', name: 'Kinderkleidung', children: [ + { id: 'kk1', name: 'Mädchen' }, { id: 'kk2', name: 'Jungen' }, { id: 'kk3', name: 'Baby' }, + ]}, + { id: 'spielzeug', name: 'Spielzeug', children: [ + { id: 'kk4', name: 'Lego & Bausteine' }, { id: 'kk5', name: 'Puppen & Figuren' }, + { id: 'kk6', name: 'Brettspiele' }, { id: 'kk7', name: 'Outdoor-Spielzeug' }, + ]}, + ]}, + { id: 'freizeit', name: 'Freizeit & Sport', children: [ + { id: 'sport', name: 'Sport', children: [ + { id: 'fk1', name: 'Fitnessgeräte' }, { id: 'fk2', name: 'Fahrräder' }, + { id: 'fk3', name: 'Wintersport' }, { id: 'fk4', name: 'Ballsport' }, + ]}, + { id: 'buecher', name: 'Bücher & Medien', children: [ + { id: 'fk5', name: 'Bücher' }, { id: 'fk6', name: 'CDs & Vinyl' }, { id: 'fk7', name: 'DVDs & Blu-ray' }, + ]}, + ]}, + { id: 'auto', name: 'Auto, Rad & Boot', children: [ + { id: 'azubehoer', name: 'Zubehör & Teile', children: [ + { id: 'ak1', name: 'Reifen & Felgen' }, { id: 'ak2', name: 'Ersatzteile' }, + { id: 'ak3', name: 'Tuning & Styling' }, + ]}, + ]}, + ], + + 'ebay-de': [ + { id: 'fashion', name: 'Kleidung & Accessoires', children: [ + { id: 'ed-damen', name: 'Damenbekleidung', children: [ + { id: 'ed1', name: 'Tops & Shirts' }, { id: 'ed2', name: 'Kleider' }, + { id: 'ed3', name: 'Hosen' }, { id: 'ed4', name: 'Jacken & Mäntel' }, + { id: 'ed5', name: 'Pullover' }, { id: 'ed6', name: 'Röcke' }, + ]}, + { id: 'ed-herren', name: 'Herrenbekleidung', children: [ + { id: 'ed7', name: 'T-Shirts' }, { id: 'ed8', name: 'Hemden' }, + { id: 'ed9', name: 'Hosen' }, { id: 'ed10', name: 'Jacken & Mäntel' }, + { id: 'ed11', name: 'Pullover & Hoodies' }, + ]}, + { id: 'ed-schuhe', name: 'Schuhe', children: [ + { id: 'ed12', name: 'Sneaker' }, { id: 'ed13', name: 'Stiefel' }, + { id: 'ed14', name: 'Sandalen' }, { id: 'ed15', name: 'Businessschuhe' }, + ]}, + { id: 'ed-acc', name: 'Accessoires', children: [ + { id: 'ed16', name: 'Uhren' }, { id: 'ed17', name: 'Schmuck' }, + { id: 'ed18', name: 'Taschen' }, { id: 'ed19', name: 'Sonnenbrillen' }, + ]}, + ]}, + { id: 'computer', name: 'Computer, Tablets & Netzwerk', children: [ + { id: 'ed-pc', name: 'Desktops & All-in-Ones', children: [ + { id: 'ed20', name: 'PC-Systeme' }, { id: 'ed21', name: 'Komponenten' }, + ]}, + { id: 'ed-nb', name: 'Notebooks & Tablets', children: [ + { id: 'ed22', name: 'Notebooks' }, { id: 'ed23', name: 'Tablets' }, + ]}, + { id: 'ed-nw', name: 'Netzwerk', children: [ + { id: 'ed24', name: 'Router & Modems' }, { id: 'ed25', name: 'NAS-Server' }, + { id: 'ed26', name: 'Switches & Hubs' }, + ]}, + { id: 'ed-zubehoer', name: 'Zubehör', children: [ + { id: 'ed27', name: 'Tastaturen & Mäuse' }, { id: 'ed28', name: 'Monitore' }, + { id: 'ed29', name: 'Speicher' }, { id: 'ed30', name: 'Kabel & Adapter' }, + ]}, + ]}, + { id: 'handy', name: 'Handys & Kommunikation', children: [ + { id: 'ed-phones', name: 'Smartphones', children: [ + { id: 'ed31', name: 'Apple iPhone' }, { id: 'ed32', name: 'Samsung Galaxy' }, + { id: 'ed33', name: 'Sonstige' }, + ]}, + { id: 'ed-phzub', name: 'Zubehör', children: [ + { id: 'ed34', name: 'Hüllen & Cover' }, { id: 'ed35', name: 'Ladegeräte' }, + { id: 'ed36', name: 'Displayschutz' }, + ]}, + ]}, + { id: 'haus', name: 'Möbel & Wohnen', children: [ + { id: 'ed-moebel', name: 'Möbel', children: [ + { id: 'ed40', name: 'Tische' }, { id: 'ed41', name: 'Stühle' }, + { id: 'ed42', name: 'Regale' }, { id: 'ed43', name: 'Betten' }, + ]}, + { id: 'ed-deko', name: 'Dekoration', children: [ + { id: 'ed44', name: 'Bilder' }, { id: 'ed45', name: 'Kerzen' }, { id: 'ed46', name: 'Vasen' }, + ]}, + ]}, + { id: 'sport', name: 'Sporting Goods', children: [ + { id: 'ed-fitness', name: 'Fitness', children: [ + { id: 'ed50', name: 'Fitnessgeräte' }, { id: 'ed51', name: 'Laufbänder' }, + ]}, + { id: 'ed-outdoor', name: 'Camping & Outdoor', children: [ + { id: 'ed52', name: 'Zelte' }, { id: 'ed53', name: 'Rucksäcke' }, + ]}, + ]}, + ], + + etsy: [ + { id: 'jewel', name: 'Schmuck', children: [ + { id: 'et-rings', name: 'Ringe' }, { id: 'et-necklaces', name: 'Ketten' }, + { id: 'et-earrings', name: 'Ohrringe' }, { id: 'et-bracelets', name: 'Armbänder' }, + ]}, + { id: 'clothing', name: 'Kleidung', children: [ + { id: 'et-dresses', name: 'Kleider' }, { id: 'et-tops', name: 'Tops & Shirts' }, + { id: 'et-outerwear', name: 'Jacken & Mäntel' }, { id: 'et-pants', name: 'Hosen' }, + { id: 'et-shoes', name: 'Schuhe' }, { id: 'et-accessories', name: 'Accessoires' }, + ]}, + { id: 'home', name: 'Haus & Wohnen', children: [ + { id: 'et-deco', name: 'Wanddeko & Kunst' }, { id: 'et-furniture', name: 'Möbel' }, + { id: 'et-kitchen', name: 'Küche & Essen' }, { id: 'et-textiles', name: 'Textilien' }, + { id: 'et-candles', name: 'Kerzen & Duft' }, + ]}, + { id: 'vintage', name: 'Vintage', children: [ + { id: 'et-vclothing', name: 'Vintage Kleidung' }, { id: 'et-vjewelry', name: 'Vintage Schmuck' }, + { id: 'et-vhome', name: 'Vintage Haus & Wohnen' }, { id: 'et-vcollect', name: 'Sammlerstücke' }, + ]}, + { id: 'craft', name: 'Bastelmaterial', children: [ + { id: 'et-fabric', name: 'Stoffe' }, { id: 'et-beads', name: 'Perlen & Zubehör' }, + { id: 'et-tools', name: 'Werkzeug' }, { id: 'et-patterns', name: 'Schnittmuster' }, + ]}, + { id: 'art', name: 'Kunst', children: [ + { id: 'et-prints', name: 'Drucke & Poster' }, { id: 'et-painting', name: 'Gemälde' }, + { id: 'et-photo', name: 'Fotografie' }, { id: 'et-sculpture', name: 'Skulpturen' }, + ]}, + { id: 'gifts', name: 'Geschenke', children: [ + { id: 'et-personal', name: 'Personalisiert' }, { id: 'et-party', name: 'Party & Feier' }, + ]}, + { id: 'bags', name: 'Taschen & Geldbörsen', children: [ + { id: 'et-handbags', name: 'Handtaschen' }, { id: 'et-backpacks', name: 'Rucksäcke' }, + { id: 'et-wallets', name: 'Geldbörsen' }, + ]}, + { id: 'electronics', name: 'Elektronik & Zubehör', children: [ + { id: 'et-phonecases', name: 'Handyhüllen' }, { id: 'et-laptopacc', name: 'Laptop-Zubehör' }, + { id: 'et-gadgets', name: 'Gadgets' }, + ]}, + ], +}; + +function flattenTree(tree, path) { + const result = []; + for (const node of tree) { + const currentPath = [...(path || []), node.name]; + if (node.children) { + for (const child of node.children) { + const childPath = [...currentPath, child.name]; + if (child.children) { + for (const leaf of child.children) { + result.push({ + id: leaf.id, name: leaf.name, + path: [...childPath, leaf.name], + pathStr: [...childPath, leaf.name].join(' > '), + search: [...childPath, leaf.name].join(' ').toLowerCase(), + }); + } + } else { + result.push({ + id: child.id, name: child.name, + path: childPath, + pathStr: childPath.join(' > '), + search: childPath.join(' ').toLowerCase(), + }); + } + } + } + } + return result; +} + +const flatCache = {}; + +export function getCategories(platformId) { + return CATEGORIES[platformId] || []; +} + +export function getCategoriesFlat(platformId) { + if (!flatCache[platformId]) { + flatCache[platformId] = flattenTree(CATEGORIES[platformId] || []); + } + return flatCache[platformId]; +} + +export function matchCategories(platformId, query) { + if (!query) return []; + const flat = getCategoriesFlat(platformId); + const q = query.toLowerCase().trim(); + const words = q.split(/\s+/); + const scored = flat.map(cat => { + let score = 0; + if (cat.search.includes(q)) score += 100; + if (cat.name.toLowerCase() === q) score += 200; + for (const w of words) { + if (w.length < 2) continue; + if (cat.search.includes(w)) score += 20; + if (cat.name.toLowerCase().includes(w)) score += 30; + } + return { ...cat, score }; + }).filter(c => c.score > 0); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 15); +} + +export function getAllPlatformIds() { + return Object.keys(CATEGORIES); +} diff --git a/lib/config-store.mjs b/lib/config-store.mjs new file mode 100644 index 0000000..6237a2b --- /dev/null +++ b/lib/config-store.mjs @@ -0,0 +1,39 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const configured = String(process.env.VENDOO_CONFIG_PATH || '').trim(); +export const CONFIG_PATH = resolve(isAbsolute(configured) ? configured : join(appRoot, configured || '.env')); + +export function readRuntimeConfig() { + return existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, 'utf8') : ''; +} + +export function setConfigValue(content, key, value) { + if (value === undefined || value === null) return content; + const clean = String(value); + if (/\r|\n/.test(clean)) throw new Error(`Mehrzeilige Konfigurationswerte sind für ${key} nicht erlaubt.`); + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped}=.*$`, 'm'); + const line = `${key}=${clean}`; + if (regex.test(content)) return content.replace(regex, line); + return `${content.trimEnd()}\n${line}\n`; +} + +export function writeRuntimeConfig(content) { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + writeFileSync(CONFIG_PATH, content, { encoding: 'utf8', mode: 0o600 }); + try { chmodSync(CONFIG_PATH, 0o600); } catch {} +} + +export function updateRuntimeConfig(values) { + let content = readRuntimeConfig(); + for (const [key, value] of Object.entries(values)) { + if (value === undefined) continue; + content = setConfigValue(content, key, value); + process.env[key] = String(value ?? ''); + } + writeRuntimeConfig(content); + return CONFIG_PATH; +} diff --git a/lib/db.mjs b/lib/db.mjs new file mode 100644 index 0000000..4529299 --- /dev/null +++ b/lib/db.mjs @@ -0,0 +1,1552 @@ +import Database from 'better-sqlite3'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { APP_ROOT, DB_PATH } from './runtime-paths.mjs'; + +const db = new Database(DB_PATH); +export { db }; + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = readFileSync(join(APP_ROOT, 'db', 'schema.sql'), 'utf-8'); +db.exec(schema); + +const MIGRATIONS = [ + ['brand', 'TEXT'], ['category', 'TEXT'], ['size', 'TEXT'], ['color', 'TEXT'], + ['condition', 'TEXT'], ['price', 'REAL'], ['status', "TEXT DEFAULT 'active'"], ['language', "TEXT DEFAULT 'de'"], + ['sku', 'TEXT'], ['deleted_at', 'TEXT'], ['storage_location', 'TEXT'], ['description_html', 'TEXT'], + ['created_by', 'INTEGER'], ['updated_by', 'INTEGER'], +]; + +for (const [col, type] of MIGRATIONS) { + try { db.exec(`ALTER TABLE listings ADD COLUMN ${col} ${type}`); } catch {} +} +try { db.exec(`CREATE TABLE IF NOT EXISTS templates (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, platform TEXT, seller_notes TEXT, language TEXT DEFAULT 'de', default_tags TEXT, created_at TEXT DEFAULT (datetime('now')))`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS mobile_upload_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + files TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_token ON mobile_upload_sessions(token_hash)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_mobile_upload_expires ON mobile_upload_sessions(expires_at)`); +} catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS flux_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + image_path TEXT NOT NULL UNIQUE, + width INTEGER NOT NULL DEFAULT 768, + height INTEGER NOT NULL DEFAULT 768, + seed INTEGER, + steps INTEGER NOT NULL DEFAULT 6, + style TEXT NOT NULL DEFAULT 'none', + edited_from INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_created ON flux_images(id DESC)`); +} catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN final_prompt TEXT`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN profile TEXT DEFAULT 'balanced'`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN duration_ms INTEGER`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN job_id TEXT`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN variant_index INTEGER`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN prompt_mode TEXT DEFAULT 'precise'`); } catch {} +try { db.exec(`ALTER TABLE flux_images ADD COLUMN parameters_json TEXT`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_favorite ON flux_images(favorite, id DESC)`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_images_job ON flux_images(job_id, variant_index)`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS image_edit_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root_path TEXT NOT NULL, + source_path TEXT NOT NULL, + output_path TEXT NOT NULL UNIQUE, + context TEXT NOT NULL DEFAULT 'generator', + operations_json TEXT NOT NULL DEFAULT '{}', + width INTEGER, + height INTEGER, + format TEXT, + quality INTEGER, + file_size INTEGER, + user_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_edit_versions_root ON image_edit_versions(root_path, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_edit_versions_output ON image_edit_versions(output_path)`); +} catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS flux_prompt_jobs ( + id TEXT PRIMARY KEY, + prompt TEXT NOT NULL, + final_prompt TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + steps INTEGER NOT NULL, + style TEXT NOT NULL DEFAULT 'none', + profile TEXT NOT NULL DEFAULT 'balanced', + prompt_mode TEXT NOT NULL DEFAULT 'precise', + assistant INTEGER NOT NULL DEFAULT 1, + variants INTEGER NOT NULL DEFAULT 1, + seed_base INTEGER NOT NULL, + seed_lock INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + message TEXT, + error_message TEXT, + completed_variants INTEGER NOT NULL DEFAULT 0, + source_job_id TEXT, + settings_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS flux_prompt_job_variants ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + variant_index INTEGER NOT NULL, + seed INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + message TEXT, + prompt_id TEXT, + image_id INTEGER, + error_message TEXT, + duration_ms INTEGER, + cancel_requested INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES flux_prompt_jobs(id) ON DELETE CASCADE, + FOREIGN KEY(image_id) REFERENCES flux_images(id) ON DELETE SET NULL, + UNIQUE(job_id, variant_index) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_jobs_status ON flux_prompt_jobs(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_job_variants_status ON flux_prompt_job_variants(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_job_variants_job ON flux_prompt_job_variants(job_id, variant_index)`); +} catch {} + +try { db.exec(`ALTER TABLE flux_prompt_jobs ADD COLUMN archived_at TEXT`); } catch {} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_flux_jobs_archive ON flux_prompt_jobs(archived_at, created_at DESC)`); } catch {} + +try { + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE, + name TEXT NOT NULL, + description TEXT, + operations_json TEXT NOT NULL DEFAULT '{}', + filename_pattern TEXT NOT NULL DEFAULT '{name}-{profile}-{index}', + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + source_count INTEGER NOT NULL DEFAULT 0, + profile_count INTEGER NOT NULL DEFAULT 0, + total_items INTEGER NOT NULL DEFAULT 0, + completed_items INTEGER NOT NULL DEFAULT 0, + failed_items INTEGER NOT NULL DEFAULT 0, + cancelled_items INTEGER NOT NULL DEFAULT 0, + pause_requested INTEGER NOT NULL DEFAULT 0, + cancel_requested INTEGER NOT NULL DEFAULT 0, + settings_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE TABLE IF NOT EXISTS image_batch_job_items ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + source_path TEXT NOT NULL, + profile_id INTEGER, + profile_key TEXT, + profile_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + settings_json TEXT NOT NULL DEFAULT '{}', + output_path TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES image_batch_jobs(id) ON DELETE CASCADE, + FOREIGN KEY(profile_id) REFERENCES image_batch_profiles(id) ON DELETE SET NULL + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_jobs_status ON image_batch_jobs(status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_items_job ON image_batch_job_items(job_id, status, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_image_batch_items_output ON image_batch_job_items(output_path)`); +} catch {} + + +try { + db.exec(`CREATE TABLE IF NOT EXISTS quality_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + grade TEXT NOT NULL DEFAULT 'E', + ready INTEGER NOT NULL DEFAULT 0, + blockers INTEGER NOT NULL DEFAULT 0, + warnings INTEGER NOT NULL DEFAULT 0, + recommendations INTEGER NOT NULL DEFAULT 0, + report_json TEXT NOT NULL DEFAULT '{}', + analyzed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reports_listing ON quality_reports(listing_id, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reports_score ON quality_reports(score, analyzed_at DESC)`); +} catch {} + +const IMAGE_BATCH_BUILTINS = [ + ['ebay-main', 'eBay Hauptbild', 'Quadratisches, helles Hauptbild für eBay.', { resize: { width: 1600, height: 1600, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 18 }, '{name}-ebay-{index}'], + ['vinted-portrait', 'Vinted Hochformat', 'Hochformat 4:5 für Vinted und mobile Feeds.', { resize: { width: 1350, height: 1688, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 14 }, '{name}-vinted-{index}'], + ['kleinanzeigen', 'Kleinanzeigen', 'Großes, ausgewogenes Querformat.', { resize: { width: 1920, height: 1440, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-kleinanzeigen-{index}'], + ['etsy', 'Etsy Produktbild', 'Hochauflösendes 4:3-Produktbild.', { resize: { width: 2000, height: 1500, fit: 'contain', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 15 }, '{name}-etsy-{index}'], + ['instagram-square', 'Instagram Quadrat', 'Quadratisches Social-Media-Format.', { resize: { width: 1080, height: 1080, fit: 'cover', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-instagram-square-{index}'], + ['instagram-portrait', 'Instagram Hochformat', '4:5-Format für Feed-Beiträge.', { resize: { width: 1080, height: 1350, fit: 'cover', background: '#ffffff' }, format: 'jpeg', quality: 90, sharpness: 12 }, '{name}-instagram-portrait-{index}'], + ['webshop', 'Webshop WebP', 'Leichtes WebP für Shops und Webseiten.', { resize: { width: 1600, height: 1600, fit: 'contain', background: '#ffffff' }, format: 'webp', quality: 84, sharpness: 10 }, '{name}-webshop-{index}'], + ['original-optimized', 'Original optimiert', 'Originalformat optimiert, ohne Vergrößerung.', { resize: { width: 2400, height: 2400, fit: 'inside', background: '#ffffff' }, format: 'jpeg', quality: 92, sharpness: 10 }, '{name}-optimiert-{index}'], +]; +try { + const seedBatchProfile = db.prepare(`INSERT OR IGNORE INTO image_batch_profiles + (key, name, description, operations_json, filename_pattern, is_builtin) VALUES (?, ?, ?, ?, ?, 1)`); + for (const [key, name, description, operations, pattern] of IMAGE_BATCH_BUILTINS) { + seedBatchProfile.run(key, name, description, JSON.stringify(operations), pattern); + } +} catch {} + + +try { + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + preset TEXT NOT NULL, + variants INTEGER NOT NULL DEFAULT 3, + source_photos TEXT NOT NULL DEFAULT '[]', + generated_photos TEXT NOT NULL DEFAULT '[]', + prompt_summary TEXT, + provider TEXT, + provider_model TEXT, + moderation_notes TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT + )`); + db.exec(`CREATE TABLE IF NOT EXISTS ai_model_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generation_id INTEGER NOT NULL, + listing_id INTEGER, + variant_index INTEGER NOT NULL DEFAULT 1, + preset TEXT NOT NULL, + image_path TEXT NOT NULL, + source_photo TEXT, + prompt TEXT, + provider TEXT, + provider_model TEXT, + moderation_result TEXT, + safety_status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_generations_listing ON ai_model_generations(listing_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_photos_listing ON ai_model_photos(listing_id)`); + +db.exec(`CREATE TABLE IF NOT EXISTS ai_model_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER, + context TEXT NOT NULL DEFAULT 'generator', + mode TEXT NOT NULL DEFAULT 'flatlay', + preset TEXT NOT NULL DEFAULT 'mixed', + variants INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'queued', + progress INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + payload_json TEXT, + generation_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_status ON ai_model_jobs(status)`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_model_jobs_listing ON ai_model_jobs(listing_id)`); +} catch {} + + + +try { db.exec(`ALTER TABLE ai_model_jobs ADD COLUMN progress_message TEXT`); } catch {} + +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN provider_model TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_generations ADD COLUMN mode TEXT`); } catch {} +try { db.exec(`ALTER TABLE ai_model_photos ADD COLUMN mode TEXT`); } catch {} + +const defaultSettingStmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); +[ + ['ai_model_photos_enabled', '1'], + ['ai_model_photo_default_variants', '1'], + ['ai_model_photo_default_preset', 'mixed'], + ['ai_model_photo_block_underwear', '1'], + ['ai_model_photo_block_kids', '1'], + ['ai_model_photo_provider', 'auto'], + ['ai_model_photo_openrouter_free_only', '1'], + ['ai_model_photo_openrouter_model', ''], + ['ai_model_photo_openai_model', 'gpt-image-1'], + ['ai_model_photo_default_mode', 'flatlay'], + ['ai_model_photo_local_enabled', '1'], + ['ai_model_photo_local_url', 'http://127.0.0.1:8188'], + ['ai_model_photo_local_workflow_path', 'local-ai/workflows/vendoo_flux_schnell_api.json'], + ['ai_model_photo_local_install_path', 'local-ai/comfyui'], + ['ai_model_photo_local_require_token', '1'], + ['ai_model_photo_use_source_reference', '1'], + ['ai_model_photo_reference_strength', '70'], + ['ai_model_photo_local_resolution', '768'], + ['ai_model_photo_preserve_logos', '1'], + ['flux_runtime_profile', 'auto'], + ['flux_default_profile', 'balanced'], + ['flux_prompt_assistant', '1'], + ['flux_default_style', 'photorealistic'], + ['flux_default_format', '768x768'], + ['flux_default_variants', '1'], + ['flux_seed_lock', '0'], + ['flux_max_parallel_jobs', '1'], +].forEach(entry => { try { defaultSettingStmt.run(...entry); } catch {} }); + +// Slice 28: entfernt die aufgegebene lokale Try-on-Architektur vollständig aus bestehenden Datenbanken. +try { db.exec('DROP TABLE IF EXISTS vto_models'); } catch {} +try { db.prepare("DELETE FROM settings WHERE key LIKE 'ai_vto_%' OR key IN ('ai_model_photo_local_tryon_workflow_path','bfl_api_key')").run(); } catch {} +try { db.prepare("UPDATE settings SET value = 'flatlay' WHERE key = 'ai_model_photo_default_mode' AND value IN ('virtual_tryon','model')").run(); } catch {} + +// Backfill SKUs for existing listings +const noSku = db.prepare("SELECT id FROM listings WHERE sku IS NULL ORDER BY id").all(); +if (noSku.length) { + const upd = db.prepare("UPDATE listings SET sku = ? WHERE id = ?"); + for (const row of noSku) { + upd.run(`VD-${String(row.id).padStart(4, '0')}`, row.id); + } +} + +function parseJsonArray(value) { + if (Array.isArray(value)) return value; + if (value === null || value === undefined || value === '') return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return String(value).split(',').map(entry => entry.trim()).filter(Boolean); + } +} + +function parseRow(row) { + if (!row) return null; + row.tags = parseJsonArray(row.tags); + row.photos = parseJsonArray(row.photos); + return row; +} + +function generateSku() { + const row = db.prepare("SELECT MAX(CAST(SUBSTR(sku, 4) AS INTEGER)) as maxNum FROM listings WHERE sku LIKE 'VD-%'").get(); + const next = (row?.maxNum || 0) + 1; + return `VD-${String(next).padStart(4, '0')}`; +} + +export function createListing(data) { + const sku = data.sku || generateSku(); + const stmt = db.prepare(` + INSERT INTO listings (platform, ai_provider, title, description, description_html, tags, photos, seller_notes, brand, category, size, color, condition, price, status, language, sku, storage_location, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.platform, data.ai_provider, data.title, data.description, + data.description_html || null, + JSON.stringify(Array.isArray(data.tags) ? data.tags : []), + JSON.stringify(Array.isArray(data.photos) ? data.photos : []), + data.seller_notes || null, + data.brand || null, data.category || null, data.size || null, data.color || null, + data.condition || null, data.price || null, data.status || 'active', data.language || 'de', sku, + data.storage_location || null, data.created_by || null, data.updated_by || data.created_by || null + ); + return getListing(result.lastInsertRowid); +} + +export function getListing(id) { + return parseRow(db.prepare('SELECT * FROM listings WHERE id = ?').get(id)); +} + +export function getListings(query, platform, status) { + let sql = 'SELECT * FROM listings WHERE deleted_at IS NULL'; + const params = []; + if (query) { sql += ' AND (title LIKE ? OR description LIKE ? OR brand LIKE ?)'; const like = `%${query}%`; params.push(like, like, like); } + if (platform) { sql += ' AND platform = ?'; params.push(platform); } + if (status) { sql += ' AND status = ?'; params.push(status); } + sql += ' ORDER BY created_at DESC'; + return db.prepare(sql).all(...params).map(parseRow); +} + +const LISTING_UPDATE_FIELDS = new Set([ + 'platform', 'ai_provider', 'title', 'description', 'description_html', 'tags', 'photos', + 'seller_notes', 'brand', 'category', 'size', 'color', 'condition', 'price', 'status', + 'language', 'sku', 'storage_location', 'updated_by', +]); + +export function updateListing(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data || {})) { + if (!LISTING_UPDATE_FIELDS.has(key) || val === undefined) continue; + fields.push(`${key} = ?`); + values.push(['tags', 'photos'].includes(key) ? JSON.stringify(Array.isArray(val) ? val : []) : val); + } + if (!fields.length) return getListing(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE listings SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getListing(id); +} + +export function deleteListing(id) { + return softDeleteListing(id); +} + +export function softDeleteListing(id) { + return db.prepare("UPDATE listings SET deleted_at = datetime('now') WHERE id = ?").run(id); +} + +export function restoreListing(id) { + return db.prepare("UPDATE listings SET deleted_at = NULL WHERE id = ?").run(id); +} + +export function getDeletedListings() { + return db.prepare("SELECT * FROM listings WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").all().map(parseRow); +} + +export function permanentlyDeleteListing(id) { + return db.prepare('DELETE FROM listings WHERE id = ?').run(id); +} + +export function cleanupTrash(days = 30) { + return db.prepare("DELETE FROM listings WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-' || ? || ' days')").run(days); +} + +export function getTrashCount() { + const row = db.prepare("SELECT COUNT(*) as count FROM listings WHERE deleted_at IS NOT NULL").get(); + return row?.count || 0; +} + +export function duplicateListing(id) { + const original = getListing(id); + if (!original) return null; + const data = { + platform: original.platform, + ai_provider: original.ai_provider, + title: (original.title || '') + ' (Kopie)', + description: original.description, + description_html: original.description_html, + tags: original.tags, + photos: original.photos, + seller_notes: original.seller_notes, + brand: original.brand, + category: original.category, + size: original.size, + color: original.color, + condition: original.condition, + price: original.price, + status: 'active', + language: original.language, + storage_location: original.storage_location, + }; + return createListing(data); +} + + +function parseAiModelGeneration(row) { + if (!row) return null; + try { row.source_photos = JSON.parse(row.source_photos || '[]'); } catch { row.source_photos = []; } + try { row.generated_photos = JSON.parse(row.generated_photos || '[]'); } catch { row.generated_photos = []; } + return row; +} + +function parseAiModelPhoto(row) { + if (!row) return null; + return row; +} + +export function createAiModelGeneration(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_generations (listing_id, context, mode, preset, variants, source_photos, generated_photos, prompt_summary, provider, provider_model, moderation_notes, safety_status, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 3, + JSON.stringify(Array.isArray(data.source_photos) ? data.source_photos : []), + JSON.stringify(Array.isArray(data.generated_photos) ? data.generated_photos : []), + data.prompt_summary || null, + data.provider || null, + data.provider_model || null, + data.moderation_notes || null, + data.safety_status || 'pending', + data.status || 'pending' + ); + return getAiModelGeneration(result.lastInsertRowid); +} + +export function updateAiModelGeneration(id, data = {}) { + const allowed = new Set(['listing_id', 'context', 'mode', 'preset', 'variants', 'source_photos', 'generated_photos', 'prompt_summary', 'provider', 'provider_model', 'moderation_notes', 'safety_status', 'status']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(['source_photos', 'generated_photos'].includes(key) ? JSON.stringify(Array.isArray(value) ? value : []) : value); + } + if (!fields.length) return getAiModelGeneration(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_generations SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelGeneration(id); +} + +export function getAiModelGeneration(id) { + return parseAiModelGeneration(db.prepare('SELECT * FROM ai_model_generations WHERE id = ?').get(id)); +} + +export function createAiModelPhoto(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_photos (generation_id, listing_id, variant_index, mode, preset, image_path, source_photo, prompt, provider, provider_model, moderation_result, safety_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.generation_id, + data.listing_id || null, + data.variant_index || 1, + data.mode || 'model', + data.preset || 'mixed', + data.image_path, + data.source_photo || null, + data.prompt || null, + data.provider || null, + data.provider_model || null, + data.moderation_result || null, + data.safety_status || 'pending' + ); + return getAiModelPhoto(result.lastInsertRowid); +} + +export function getAiModelPhoto(id) { + return parseAiModelPhoto(db.prepare('SELECT * FROM ai_model_photos WHERE id = ?').get(id)); +} + +export function getAiModelPhotosForListing(listingId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE listing_id = ? ORDER BY created_at DESC, variant_index ASC').all(listingId).map(parseAiModelPhoto); +} + +export function getAiModelPhotosForGeneration(generationId) { + return db.prepare('SELECT * FROM ai_model_photos WHERE generation_id = ? ORDER BY variant_index ASC').all(generationId).map(parseAiModelPhoto); +} + +export function getAiModelGenerationsForListing(listingId) { + return db.prepare("SELECT * FROM ai_model_generations WHERE listing_id = ? ORDER BY created_at DESC, id DESC").all(listingId).map(parseAiModelGeneration); +} + +export function createAiModelJob(data) { + const stmt = db.prepare(` + INSERT INTO ai_model_jobs (listing_id, context, mode, preset, variants, status, progress, progress_message, error_message, payload_json, generation_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + data.listing_id || null, + data.context || 'generator', + data.mode || 'model', + data.preset || 'mixed', + data.variants || 1, + data.status || 'queued', + data.progress ?? 0, + data.progress_message || null, + data.error_message || null, + JSON.stringify(data.payload_json || {}), + data.generation_id || null + ); + return getAiModelJob(result.lastInsertRowid); +} + +export function getAiModelJob(id) { + const row = db.prepare('SELECT * FROM ai_model_jobs WHERE id = ?').get(id); + if (!row) return null; + try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } + return row; +} + +export function updateAiModelJob(id, data = {}) { + const allowed = new Set(['listing_id','context','mode','preset','variants','status','progress','progress_message','error_message','payload_json','generation_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(data)) { + if (!allowed.has(key) || value === undefined) continue; + fields.push(`${key} = ?`); + values.push(key === 'payload_json' ? JSON.stringify(value || {}) : value); + } + if (!fields.length) return getAiModelJob(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE ai_model_jobs SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getAiModelJob(id); +} + +export function getPendingAiModelJobs(limit = 10) { + return db.prepare("SELECT * FROM ai_model_jobs WHERE status IN ('queued','retry') ORDER BY id ASC LIMIT ?").all(limit).map(row => { try { row.payload_json = JSON.parse(row.payload_json || '{}'); } catch { row.payload_json = {}; } return row; }); +} + +export function getSettings() { + const rows = db.prepare('SELECT key, value FROM settings').all(); + const settings = {}; + for (const row of rows) settings[row.key] = row.value; + return settings; +} + +export function updateSettings(settings) { + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + const transaction = db.transaction((entries) => { + for (const [key, value] of entries) stmt.run(key, value); + }); + transaction(Object.entries(settings)); + return getSettings(); +} + +function parseFluxImage(row) { + if (!row) return null; + try { row.parameters = JSON.parse(row.parameters_json || '{}'); } catch { row.parameters = {}; } + row.favorite = Number(row.favorite || 0) === 1; + return row; +} + +export function createFluxImage(data = {}) { + const prompt = String(data.prompt || '').trim().slice(0, 4000); + const imagePath = String(data.image_path || '').trim(); + if (!prompt || !imagePath) throw new Error('Prompt und Bildpfad sind erforderlich.'); + const existing = parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE image_path = ?').get(imagePath)); + if (existing) return existing; + const result = db.prepare(`INSERT INTO flux_images + (prompt, image_path, width, height, seed, steps, style, edited_from, final_prompt, profile, duration_ms, favorite, job_id, variant_index, prompt_mode, parameters_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + prompt, + imagePath, + Math.max(256, Number(data.width) || 768), + Math.max(256, Number(data.height) || 768), + Number.isFinite(Number(data.seed)) ? Number(data.seed) : null, + Math.max(1, Math.min(50, Number(data.steps) || 6)), + String(data.style || 'none').slice(0, 40), + Number.isFinite(Number(data.edited_from)) ? Number(data.edited_from) : null, + String(data.final_prompt || '').trim().slice(0, 5000) || null, + String(data.profile || 'balanced').slice(0, 40), + Number.isFinite(Number(data.duration_ms)) ? Math.max(0, Math.round(Number(data.duration_ms))) : null, + data.favorite === true || Number(data.favorite) === 1 ? 1 : 0, + String(data.job_id || '').trim().slice(0, 80) || null, + Number.isFinite(Number(data.variant_index)) ? Math.max(1, Math.round(Number(data.variant_index))) : null, + String(data.prompt_mode || 'precise').slice(0, 40), + JSON.stringify(data.parameters && typeof data.parameters === 'object' ? data.parameters : {}), + ); + return parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE id = ?').get(result.lastInsertRowid)); +} + +export function getFluxImages(limit = 40, { favoritesOnly = false } = {}) { + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 40)); + const rows = favoritesOnly + ? db.prepare('SELECT * FROM flux_images WHERE favorite = 1 ORDER BY id DESC LIMIT ?').all(safeLimit) + : db.prepare('SELECT * FROM flux_images ORDER BY id DESC LIMIT ?').all(safeLimit); + return rows.map(parseFluxImage); +} + +export function paginateFluxImages({ page = 1, pageSize = 5, favoritesOnly = false } = {}) { + const safePageSize = Math.max(5, Math.min(15, Number(pageSize) || 5)); + const requestedPage = Math.max(1, Number(page) || 1); + const where = favoritesOnly ? 'WHERE favorite = 1' : ''; + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM flux_images ${where}`).get()?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM flux_images ${where} ORDER BY id DESC LIMIT ? OFFSET ?`) + .all(safePageSize, (safePage - 1) * safePageSize); + return { + items: rows.map(parseFluxImage), + pagination: { page: safePage, page_size: safePageSize, total, total_pages: totalPages }, + }; +} + +export function getFluxImage(id) { + return parseFluxImage(db.prepare('SELECT * FROM flux_images WHERE id = ?').get(Number(id))); +} + +export function setFluxImageFavorite(id, favorite = true) { + db.prepare('UPDATE flux_images SET favorite = ? WHERE id = ?').run(favorite ? 1 : 0, Number(id)); + return getFluxImage(id); +} + +export function deleteFluxImage(id) { + const image = getFluxImage(id); + if (!image) return null; + db.prepare('DELETE FROM flux_images WHERE id = ?').run(Number(id)); + return image; +} + + +function normalizeMediaPath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +export function listMediaLibraryItems({ source = 'all', search = '', favoritesOnly = false, sort = 'newest' } = {}) { + const map = new Map(); + const add = (raw = {}) => { + const imagePath = normalizeMediaPath(raw.image_path); + if (!imagePath) return; + const existing = map.get(imagePath); + const sources = new Set([...(existing?.sources || []), ...(raw.sources || []), raw.source].filter(Boolean)); + const priority = { batch: 5, flux: 4, edited: 3, ai: 2, listing: 1 }; + const shouldReplace = !existing || (priority[raw.source] || 0) > (priority[existing.source] || 0); + const base = shouldReplace ? { ...existing, ...raw } : { ...raw, ...existing }; + base.image_path = imagePath; + base.sources = [...sources]; + base.favorite = Boolean(existing?.favorite || raw.favorite); + base.created_at = [existing?.created_at, raw.created_at].filter(Boolean).sort().reverse()[0] || null; + map.set(imagePath, base); + }; + + for (const row of db.prepare('SELECT * FROM flux_images ORDER BY id DESC').all()) { + add({ id: `flux:${row.id}`, source: 'flux', sources: ['flux'], flux_id: row.id, image_path: row.image_path, + title: row.prompt || 'FLUX-Bild', prompt: row.prompt || '', final_prompt: row.final_prompt || '', favorite: Number(row.favorite || 0) === 1, + width: row.width, height: row.height, seed: row.seed, job_id: row.job_id, variant_index: row.variant_index, created_at: row.created_at }); + } + for (const row of db.prepare('SELECT * FROM image_edit_versions ORDER BY id DESC').all()) { + const isBatch = String(row.context || '') === 'batch'; + add({ id: `${isBatch ? 'batch' : 'edited'}:${row.id}`, source: isBatch ? 'batch' : 'edited', sources: [isBatch ? 'batch' : 'edited'], edit_id: row.id, image_path: row.output_path, + title: isBatch ? 'Bildproduktion' : 'Bearbeitete Bildversion', root_path: row.root_path, width: row.width, height: row.height, format: row.format, created_at: row.created_at }); + } + for (const row of db.prepare('SELECT * FROM ai_model_photos ORDER BY id DESC').all()) { + add({ id: `ai:${row.id}`, source: 'ai', sources: ['ai'], ai_id: row.id, image_path: row.image_path, + title: row.prompt || `AI-Bild ${row.variant_index || ''}`.trim(), prompt: row.prompt || '', listing_id: row.listing_id, + provider: row.provider_model || row.provider || '', preset: row.preset || '', created_at: row.created_at }); + } + for (const row of db.prepare("SELECT id, title, photos, created_at FROM listings WHERE deleted_at IS NULL ORDER BY id DESC").all()) { + for (const imagePath of parseJsonArray(row.photos)) { + add({ id: `listing:${row.id}:${normalizeMediaPath(imagePath)}`, source: 'listing', sources: ['listing'], listing_id: row.id, + image_path: imagePath, title: row.title || `Listing ${row.id}`, created_at: row.created_at }); + } + } + + const needle = String(search || '').trim().toLowerCase(); + const selectedSource = String(source || 'all').toLowerCase(); + let items = [...map.values()].filter(item => { + if (selectedSource !== 'all' && !item.sources.includes(selectedSource)) return false; + if (favoritesOnly && !item.favorite) return false; + if (!needle) return true; + return [item.title, item.prompt, item.final_prompt, item.image_path, item.provider, item.preset] + .some(value => String(value || '').toLowerCase().includes(needle)); + }); + if (sort === 'oldest') items.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || ''))); + else if (sort === 'name') items.sort((a, b) => String(a.title || a.image_path).localeCompare(String(b.title || b.image_path), 'de')); + else items.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); + return items; +} + +export function setMediaLibraryFavorite(imagePath, favorite = true) { + const normalized = normalizeMediaPath(imagePath); + const result = db.prepare('UPDATE flux_images SET favorite = ? WHERE image_path = ?').run(favorite ? 1 : 0, normalized); + return { ok: result.changes > 0, image_path: normalized, favorite: Boolean(favorite) }; +} + +export function getListingReferencedMediaPaths() { + const referenced = new Set(); + for (const row of db.prepare("SELECT photos FROM listings WHERE deleted_at IS NULL").all()) { + for (const imagePath of parseJsonArray(row.photos)) referenced.add(normalizeMediaPath(imagePath)); + } + return referenced; +} + +export function removeMediaLibraryRecords(imagePaths = []) { + const normalized = [...new Set((imagePaths || []).map(normalizeMediaPath).filter(Boolean))]; + const tx = db.transaction(() => { + for (const imagePath of normalized) { + db.prepare('DELETE FROM flux_images WHERE image_path = ?').run(imagePath); + db.prepare('DELETE FROM image_edit_versions WHERE output_path = ?').run(imagePath); + db.prepare('DELETE FROM ai_model_photos WHERE image_path = ?').run(imagePath); + } + }); + tx(); + return normalized; +} + +function parseImageEditVersion(row) { + if (!row) return null; + try { row.operations = JSON.parse(row.operations_json || '{}'); } catch { row.operations = {}; } + return row; +} + +export function getImageEditRoot(path) { + const normalized = String(path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!normalized) return null; + const row = db.prepare('SELECT root_path FROM image_edit_versions WHERE output_path = ? ORDER BY id DESC LIMIT 1').get(normalized); + return row?.root_path || null; +} + +export function createImageEditVersion(data = {}) { + const rootPath = String(data.root_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + const sourcePath = String(data.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + const outputPath = String(data.output_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!rootPath || !sourcePath || !outputPath) throw new Error('Bildversion benötigt Original-, Quell- und Zielpfad.'); + const result = db.prepare(`INSERT INTO image_edit_versions + (root_path, source_path, output_path, context, operations_json, width, height, format, quality, file_size, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + rootPath, + sourcePath, + outputPath, + String(data.context || 'generator').slice(0, 40), + JSON.stringify(data.operations && typeof data.operations === 'object' ? data.operations : {}), + Number.isFinite(Number(data.width)) ? Math.round(Number(data.width)) : null, + Number.isFinite(Number(data.height)) ? Math.round(Number(data.height)) : null, + String(data.format || '').slice(0, 20) || null, + Number.isFinite(Number(data.quality)) ? Math.round(Number(data.quality)) : null, + Number.isFinite(Number(data.file_size)) ? Math.round(Number(data.file_size)) : null, + Number.isFinite(Number(data.user_id)) ? Number(data.user_id) : null, + ); + return parseImageEditVersion(db.prepare('SELECT * FROM image_edit_versions WHERE id = ?').get(result.lastInsertRowid)); +} + +export function getImageEditVersions(path, limit = 50) { + const normalized = String(path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); + if (!normalized) return { root_path: null, versions: [] }; + const rootPath = getImageEditRoot(normalized) || normalized; + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); + const rows = db.prepare('SELECT * FROM image_edit_versions WHERE root_path = ? ORDER BY id DESC LIMIT ?').all(rootPath, safeLimit).map(parseImageEditVersion); + return { root_path: rootPath, versions: rows }; +} + + +export function createMobileUploadSession(data) { + const stmt = db.prepare(` + INSERT INTO mobile_upload_sessions (token_hash, user_id, listing_id, context, files, status, expires_at) + VALUES (?, ?, ?, ?, '[]', 'active', ?) + `); + const result = stmt.run( + data.token_hash, + data.user_id || null, + data.listing_id || null, + data.context || 'generator', + data.expires_at + ); + return getMobileUploadSessionById(result.lastInsertRowid, data.user_id || null); +} + +function parseMobileUploadSession(row) { + if (!row) return null; + try { row.files = JSON.parse(row.files || '[]'); } catch { row.files = []; } + return row; +} + +export function getMobileUploadSessionById(id, userId = null) { + const row = userId + ? db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').get(id, userId) + : db.prepare('SELECT * FROM mobile_upload_sessions WHERE id = ?').get(id); + return parseMobileUploadSession(row); +} + +export function getMobileUploadSessionByTokenHash(tokenHash) { + return parseMobileUploadSession(db.prepare('SELECT * FROM mobile_upload_sessions WHERE token_hash = ?').get(tokenHash)); +} + +export function appendMobileUploadFiles(id, filenames) { + const current = getMobileUploadSessionById(id); + if (!current) return null; + const merged = [...new Set([...(current.files || []), ...(filenames || [])])].slice(0, 10); + db.prepare("UPDATE mobile_upload_sessions SET files = ?, updated_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(merged), id); + return getMobileUploadSessionById(id); +} + +export function updateMobileUploadSession(id, patch = {}) { + const allowed = new Set(['status', 'listing_id']); + const fields = []; + const values = []; + for (const [key, value] of Object.entries(patch)) { + if (!allowed.has(key)) continue; + fields.push(`${key} = ?`); + values.push(value); + } + if (!fields.length) return getMobileUploadSessionById(id); + fields.push("updated_at = datetime('now')"); + values.push(id); + db.prepare(`UPDATE mobile_upload_sessions SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return getMobileUploadSessionById(id); +} + +export function deleteMobileUploadSession(id, userId = null) { + if (userId) return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ? AND (user_id = ? OR user_id IS NULL)').run(id, userId); + return db.prepare('DELETE FROM mobile_upload_sessions WHERE id = ?').run(id); +} + +export function cleanupMobileUploadSessions() { + return db.prepare("DELETE FROM mobile_upload_sessions WHERE expires_at < datetime('now') OR status = 'closed'").run(); +} + +// --- Seed example templates --- +const tplCount = db.prepare("SELECT COUNT(*) as c FROM templates").get(); +if (tplCount.c === 0) { + const seedTpl = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + seedTpl.run('Sneaker & Schuhe', 'vinted', + 'Originalverpackung vorhanden. Versand mit DHL, gut gepolstert. Tierfreier Nichtraucherhaushalt. Bei Fragen gerne melden!', + 'de', JSON.stringify(['#sneaker', '#schuhe', '#nike', '#adidas', '#secondhand'])); + seedTpl.run('Vintage Kleidung', 'vinted', + 'Vintage-Stück in gepflegtem Zustand. Maße siehe Beschreibung. Versand innerhalb von 1-2 Werktagen. Privatverkauf, keine Rücknahme.', + 'de', JSON.stringify(['#vintage', '#retro', '#secondhand', '#fashion', '#preloved'])); + seedTpl.run('Elektronik & Zubehör', 'ebay-de', + 'Funktioniert einwandfrei, wurde getestet. Originalzubehör dabei, sofern nicht anders angegeben. Versand als versichertes Paket.', + 'de', JSON.stringify(['Elektronik', 'Technik', 'Zubehör', 'gebraucht', 'getestet'])); +} + +// --- Templates --- +export function createTemplate(data) { + const stmt = db.prepare('INSERT INTO templates (name, platform, seller_notes, language, default_tags) VALUES (?, ?, ?, ?, ?)'); + const result = stmt.run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || [])); + return getTemplate(result.lastInsertRowid); +} + +export function getTemplate(id) { + const row = db.prepare('SELECT * FROM templates WHERE id = ?').get(id); + if (!row) return null; + row.default_tags = JSON.parse(row.default_tags || '[]'); + return row; +} + +export function getTemplates() { + return db.prepare('SELECT * FROM templates ORDER BY name').all().map(r => { + r.default_tags = JSON.parse(r.default_tags || '[]'); + return r; + }); +} + +export function updateTemplate(id, data) { + db.prepare('UPDATE templates SET name = ?, platform = ?, seller_notes = ?, language = ?, default_tags = ? WHERE id = ?') + .run(data.name, data.platform || null, data.seller_notes || '', data.language || 'de', JSON.stringify(data.default_tags || []), id); + return getTemplate(id); +} + +export function deleteTemplate(id) { + return db.prepare('DELETE FROM templates WHERE id = ?').run(id); +} + +// --- Publish Log --- +try { db.exec(`CREATE TABLE IF NOT EXISTS publish_log (id INTEGER PRIMARY KEY AUTOINCREMENT, listing_id INTEGER NOT NULL, platform TEXT NOT NULL, status TEXT DEFAULT 'pending', external_id TEXT, external_url TEXT, published_at TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE)`); } catch {} + +export function createPublishLog(listingId, platform, status, externalId, externalUrl) { + const stmt = db.prepare('INSERT INTO publish_log (listing_id, platform, status, external_id, external_url, published_at) VALUES (?, ?, ?, ?, ?, datetime(?))'); + stmt.run(listingId, platform, status || 'pending', externalId || null, externalUrl || null, status === 'published' ? 'now' : null); +} + +export function updatePublishLog(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + if (data.status === 'published') { fields.push("published_at = datetime('now')"); } + values.push(id); + db.prepare(`UPDATE publish_log SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function getPublishLogs(listingId) { + return db.prepare('SELECT * FROM publish_log WHERE listing_id = ? ORDER BY created_at DESC').all(listingId); +} + +export function getPublishStatus(listingIds) { + if (!listingIds.length) return {}; + const placeholders = listingIds.map(() => '?').join(','); + const rows = db.prepare(`SELECT listing_id, platform, status FROM publish_log WHERE listing_id IN (${placeholders})`).all(...listingIds); + const result = {}; + for (const r of rows) { + if (!result[r.listing_id]) result[r.listing_id] = {}; + result[r.listing_id][r.platform] = r.status; + } + return result; +} + +// --- eBay Queue --- +try { db.exec(`CREATE TABLE IF NOT EXISTS ebay_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + status TEXT DEFAULT 'pending', + ebay_item_id TEXT, + ebay_url TEXT, + error TEXT, + retries INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function addToEbayQueue(listingIds) { + const stmt = db.prepare('INSERT OR IGNORE INTO ebay_queue (listing_id) VALUES (?)'); + const existing = db.prepare('SELECT listing_id FROM ebay_queue WHERE listing_id = ? AND status IN (?, ?)'); + const added = []; + for (const id of listingIds) { + const ex = existing.get(id, 'pending', 'uploading'); + if (!ex) { + stmt.run(id); + added.push(id); + } + } + return added; +} + +export function getEbayQueue(status) { + let sql = 'SELECT q.*, l.title, l.sku, l.platform FROM ebay_queue q JOIN listings l ON q.listing_id = l.id'; + if (status) { sql += ' WHERE q.status = ?'; return db.prepare(sql + ' ORDER BY q.created_at').all(status); } + return db.prepare(sql + ' ORDER BY q.created_at DESC').all(); +} + +export function updateEbayQueue(id, data) { + const fields = ["updated_at = datetime('now')"]; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + values.push(id); + db.prepare(`UPDATE ebay_queue SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeFromEbayQueue(id) { + db.prepare('DELETE FROM ebay_queue WHERE id = ?').run(id); +} + +export function clearEbayQueue(status) { + if (status) db.prepare('DELETE FROM ebay_queue WHERE status = ?').run(status); + else db.prepare('DELETE FROM ebay_queue').run(); +} + +// --- Scheduled Publishes --- +try { db.exec(`CREATE TABLE IF NOT EXISTS scheduled_publish ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + scheduled_at TEXT NOT NULL, + status TEXT DEFAULT 'scheduled', + error TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE +)`); } catch {} + +export function schedulePublish(listingId, platform, scheduledAt) { + const existing = db.prepare('SELECT id FROM scheduled_publish WHERE listing_id = ? AND platform = ? AND status = ?').get(listingId, platform, 'scheduled'); + if (existing) { + db.prepare('UPDATE scheduled_publish SET scheduled_at = ? WHERE id = ?').run(scheduledAt, existing.id); + return existing.id; + } + const r = db.prepare('INSERT INTO scheduled_publish (listing_id, platform, scheduled_at) VALUES (?, ?, ?)').run(listingId, platform, scheduledAt); + return r.lastInsertRowid; +} + +export function getScheduledPublishes(status) { + let sql = 'SELECT s.*, l.title, l.sku, l.price FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id'; + if (status) { sql += ' WHERE s.status = ?'; return db.prepare(sql + ' ORDER BY s.scheduled_at').all(status); } + return db.prepare(sql + ' ORDER BY s.scheduled_at').all(); +} + +export function getDueScheduled() { + return db.prepare("SELECT s.*, l.title, l.sku, l.platform as listing_platform FROM scheduled_publish s JOIN listings l ON s.listing_id = l.id WHERE s.status = 'scheduled' AND s.scheduled_at <= datetime('now') ORDER BY s.scheduled_at").all(); +} + +export function updateScheduledPublish(id, data) { + const fields = []; + const values = []; + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + fields.push(`${key} = ?`); + values.push(val); + } + if (!fields.length) return; + values.push(id); + db.prepare(`UPDATE scheduled_publish SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function removeScheduledPublish(id) { + db.prepare('DELETE FROM scheduled_publish WHERE id = ?').run(id); +} + +// --- Inventory / Storage --- +export function getStorageLocations() { + return db.prepare(` + SELECT storage_location, COUNT(*) as count, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'sold' THEN 1 ELSE 0 END) as sold, + SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END) as reserved, + COALESCE(SUM(CASE WHEN status = 'active' THEN price ELSE 0 END), 0) as total_value + FROM listings + WHERE deleted_at IS NULL AND storage_location IS NOT NULL AND storage_location != '' + GROUP BY storage_location ORDER BY storage_location + `).all(); +} + +export function getListingsByLocation(location) { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND storage_location = ? ORDER BY created_at DESC" + ).all(location).map(parseRow); +} + +export function getUnassignedListings() { + return db.prepare( + "SELECT * FROM listings WHERE deleted_at IS NULL AND (storage_location IS NULL OR storage_location = '') ORDER BY created_at DESC" + ).all().map(parseRow); +} + +export function bulkUpdateLocation(ids, location) { + const stmt = db.prepare("UPDATE listings SET storage_location = ?, updated_at = datetime('now') WHERE id = ?"); + const tx = db.transaction(() => { for (const id of ids) stmt.run(location, id); }); + tx(); + return ids.length; +} + +// --- CSV Export --- +export function getListingsForExport(platform, status) { + return getListings(null, platform, status); +} + +// --- Studio Flow Dashboard --- +function listingCompleteness(listing) { + const checks = [ + [listing.title, 14], + [listing.description, 16], + [Array.isArray(listing.photos) && listing.photos.length > 0, 18], + [listing.brand, 8], + [listing.category, 12], + [listing.condition, 10], + [Number(listing.price) > 0, 10], + [listing.size, 4], + [listing.color, 4], + [Array.isArray(listing.tags) && listing.tags.length > 0, 4], + ]; + return checks.reduce((score, [value, weight]) => score + (value ? weight : 0), 0); +} + +function sqliteDateToIso(value) { + if (!value) return null; + if (value.includes('T')) return value; + return `${value.replace(' ', 'T')}Z`; +} + +export function getDashboardWorkflow(limitPerStage = 3) { + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare("SELECT * FROM scheduled_publish WHERE status = 'scheduled' ORDER BY scheduled_at").all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + + const latestPublish = new Map(); + for (const row of publishRows) { + if (!latestPublish.has(row.listing_id)) latestPublish.set(row.listing_id, row); + } + const activeSchedule = new Map(scheduleRows.map(row => [row.listing_id, row])); + const latestQueue = new Map(); + for (const row of queueRows) { + if (!latestQueue.has(row.listing_id)) latestQueue.set(row.listing_id, row); + } + + const stages = { + captured: [], + ai_review: [], + ready: [], + scheduled: [], + published: [], + sold: [], + }; + + const itemById = new Map(); + for (const listing of listings) { + const completeness = listingCompleteness(listing); + const publish = latestPublish.get(listing.id) || null; + const schedule = activeSchedule.get(listing.id) || null; + const queue = latestQueue.get(listing.id) || null; + + let stage = 'ready'; + if (listing.status === 'sold') stage = 'sold'; + else if (schedule) stage = 'scheduled'; + else if (publish?.status === 'published' || queue?.status === 'published') stage = 'published'; + else if (completeness < 55) stage = 'captured'; + else if (completeness < 85) stage = 'ai_review'; + + const item = { + id: listing.id, + title: listing.title || 'Unbenanntes Listing', + sku: listing.sku || `VD-${String(listing.id).padStart(4, '0')}`, + platform: listing.platform, + aiProvider: listing.ai_provider, + photos: listing.photos || [], + price: listing.price, + status: listing.status, + storageLocation: listing.storage_location, + brand: listing.brand, + category: listing.category, + size: listing.size, + color: listing.color, + condition: listing.condition, + completeness, + stage, + scheduledAt: schedule?.scheduled_at || null, + publishStatus: publish?.status || queue?.status || null, + queueStatus: queue?.status || null, + createdAt: listing.created_at, + updatedAt: listing.updated_at, + }; + stages[stage].push(item); + itemById.set(listing.id, item); + } + + const attention = []; + const addAttention = (listingId, type, message, action, severity = 'warning') => { + const item = itemById.get(listingId); + if (!item) return; + attention.push({ + id: `${listingId}:${type}`, + listingId, + type, + message, + action, + severity, + title: item.title, + sku: item.sku, + photo: item.photos?.[0] || null, + createdAt: item.updatedAt || item.createdAt, + }); + }; + + for (const row of queueRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', row.error || 'eBay-Veröffentlichung fehlgeschlagen', 'Erneut versuchen', 'danger'); + } + for (const row of publishRows.filter(row => row.status === 'failed')) { + addAttention(row.listing_id, 'publish_failed', 'Veröffentlichung fehlgeschlagen', 'Prüfen', 'danger'); + } + for (const item of itemById.values()) { + if (!Number(item.price)) addAttention(item.id, 'missing_price', 'Preis fehlt', 'Preis setzen'); + if (!item.storageLocation && item.status !== 'sold') addAttention(item.id, 'missing_location', 'Lagerort fehlt', 'Lagerort setzen'); + if (item.completeness < 85) addAttention(item.id, 'low_confidence', `Listing-Qualität ${item.completeness}%`, 'Prüfen'); + } + + const attentionPriority = { danger: 0, warning: 1, info: 2 }; + attention.sort((a, b) => { + const severity = (attentionPriority[a.severity] ?? 9) - (attentionPriority[b.severity] ?? 9); + if (severity) return severity; + return String(b.createdAt || '').localeCompare(String(a.createdAt || '')); + }); + + const activity = []; + for (const listing of listings.slice(0, 12)) { + activity.push({ + type: listing.status === 'sold' ? 'sold' : 'created', + listingId: listing.id, + text: listing.status === 'sold' + ? `${listing.title || listing.sku} wurde als verkauft markiert` + : `${listing.title || listing.sku} wurde erfasst`, + at: sqliteDateToIso(listing.updated_at || listing.created_at), + }); + } + for (const row of publishRows.slice(0, 12)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + const verbs = { published: 'veröffentlicht', failed: 'konnte nicht veröffentlicht werden', copied: 'für Smart Copy vorbereitet', pending: 'zur Veröffentlichung vorgemerkt' }; + activity.push({ + type: row.status || 'published', + listingId: row.listing_id, + text: `${item.title} wurde auf ${row.platform} ${verbs[row.status] || row.status}`, + at: sqliteDateToIso(row.published_at || row.created_at), + }); + } + for (const row of scheduleRows.slice(0, 8)) { + const item = itemById.get(row.listing_id); + if (!item) continue; + activity.push({ + type: 'scheduled', + listingId: row.listing_id, + text: `${item.title} wurde für ${row.platform} geplant`, + at: sqliteDateToIso(row.created_at), + }); + } + activity.sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))); + + const orderedStages = {}; + for (const [key, items] of Object.entries(stages)) { + orderedStages[key] = { + count: items.length, + items: items.slice(0, Math.max(1, Number(limitPerStage) || 3)), + }; + } + + return { + stages: orderedStages, + attention: attention.slice(0, 6), + attentionCount: attention.length, + activity: activity.slice(0, 8), + totals: { + listings: listings.length, + active: listings.filter(item => item.status === 'active').length, + sold: listings.filter(item => item.status === 'sold').length, + inventoryValue: listings.reduce((sum, item) => sum + (item.status !== 'sold' ? Number(item.price) || 0 : 0), 0), + }, + }; +} + +export function getDashboardAnalytics(days = 30) { + const periodDays = Math.min(Math.max(Number(days) || 30, 7), 365); + const listings = getListings(); + const publishRows = db.prepare('SELECT * FROM publish_log ORDER BY created_at DESC').all(); + const queueRows = db.prepare('SELECT * FROM ebay_queue ORDER BY created_at DESC').all(); + const scheduleRows = db.prepare('SELECT * FROM scheduled_publish ORDER BY created_at DESC').all(); + const locations = getStorageLocations(); + + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - (periodDays - 1)); + const weekStart = new Date(now); + weekStart.setDate(weekStart.getDate() - 7); + const todayKey = now.toISOString().slice(0, 10); + + const soldListings = listings.filter(item => item.status === 'sold'); + const dateOf = item => { + const raw = item.updated_at || item.created_at; + if (!raw) return null; + const parsed = new Date(String(raw).replace(' ', 'T') + (String(raw).includes('Z') ? '' : 'Z')); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const timelineMap = new Map(); + for (let index = 0; index < periodDays; index++) { + const date = new Date(start); + date.setDate(start.getDate() + index); + const key = date.toISOString().slice(0, 10); + timelineMap.set(key, { date: key, revenue: 0, sales: 0 }); + } + + const platformRevenue = new Map(); + const categoryRevenue = new Map(); + for (const listing of soldListings) { + const date = dateOf(listing); + const price = Number(listing.price) || 0; + if (date) { + const key = date.toISOString().slice(0, 10); + const bucket = timelineMap.get(key); + if (bucket) { + bucket.revenue += price; + bucket.sales += 1; + } + } + const platform = listing.platform || 'Sonstige'; + platformRevenue.set(platform, (platformRevenue.get(platform) || 0) + price); + const category = listing.category ? String(listing.category).split('>').pop().trim() : 'Sonstiges'; + categoryRevenue.set(category, (categoryRevenue.get(category) || 0) + price); + } + + const successes = publishRows.filter(row => row.status === 'published').length; + const failures = publishRows.filter(row => row.status === 'failed').length + queueRows.filter(row => row.status === 'failed').length; + const publishAttempts = successes + failures; + const quality = listings.length + ? Math.round(listings.reduce((sum, item) => sum + listingCompleteness(item), 0) / listings.length) + : 0; + + const workflow = getDashboardWorkflow(6); + const recentActivity = workflow.activity.slice(0, 6); + const notifications = workflow.attention.slice(0, 6); + const mobileUploads = db.prepare("SELECT COUNT(*) AS count FROM mobile_upload_sessions WHERE created_at >= datetime('now', '-24 hours')").get()?.count || 0; + + const topSellers = [...soldListings] + .sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)) + .slice(0, 5) + .map(item => ({ + id: item.id, + title: item.title, + sku: item.sku, + platform: item.platform, + revenue: Number(item.price) || 0, + sales: 1, + status: item.status, + photo: item.photos?.[0] || null, + size: item.size, + color: item.color, + })); + + return { + periodDays, + generatedAt: new Date().toISOString(), + kpis: { + revenueToday: soldListings.filter(item => String(item.updated_at || item.created_at || '').startsWith(todayKey)).reduce((sum, item) => sum + (Number(item.price) || 0), 0), + sales7Days: soldListings.filter(item => { const date = dateOf(item); return date && date >= weekStart; }).length, + activeListings: listings.filter(item => !['sold', 'draft'].includes(item.status)).length, + drafts: listings.filter(item => item.status === 'draft').length, + inventoryValue: listings.filter(item => item.status !== 'sold').reduce((sum, item) => sum + (Number(item.price) || 0), 0), + publishSuccessRate: publishAttempts ? Math.round((successes / publishAttempts) * 1000) / 10 : 100, + quality, + }, + timeline: [...timelineMap.values()], + platformRevenue: [...platformRevenue.entries()].map(([platform, revenue]) => ({ platform, revenue })).sort((a, b) => b.revenue - a.revenue), + categoryRevenue: [...categoryRevenue.entries()].map(([category, revenue]) => ({ category, revenue })).sort((a, b) => b.revenue - a.revenue).slice(0, 6), + focus: { + ready: workflow.stages?.ready?.count || 0, + priceChecks: workflow.attention.filter(item => item.type === 'missing_price').length, + failedPublishes: failures, + mobileUploads, + activeLocations: locations.filter(item => Number(item.count) > 0).length, + }, + topSellers, + activity: recentActivity, + notifications, + system: { + healthy: true, + queuePending: queueRows.filter(item => item.status === 'pending').length, + scheduled: scheduleRows.filter(item => item.status === 'scheduled').length, + }, + }; +} + +// --- Publishing Hardening 1.29.0 --- +try { + db.exec(`CREATE TABLE IF NOT EXISTS publishing_jobs ( + id TEXT PRIMARY KEY, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'api', + action TEXT NOT NULL DEFAULT 'publish', + status TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'queued', + idempotency_key TEXT NOT NULL UNIQUE, + external_id TEXT, + external_url TEXT, + offer_id TEXT, + retries INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_attempt_at TEXT, + claimed_by TEXT, + claimed_at TEXT, + error_code TEXT, + error_message TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE TABLE IF NOT EXISTS publishing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT, + listing_id INTEGER NOT NULL, + platform TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(job_id) REFERENCES publishing_jobs(id) ON DELETE SET NULL, + FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_jobs_status ON publishing_jobs(status, next_attempt_at, created_at)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_jobs_listing ON publishing_jobs(listing_id, platform, created_at DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_events_job ON publishing_events(job_id, id DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_publishing_events_listing ON publishing_events(listing_id, id DESC)`); +} catch {} + +for (const [column, type] of [ + ['offer_id', 'TEXT'], ['sync_status', 'TEXT'], ['last_synced_at', 'TEXT'], ['ended_at', 'TEXT'], ['updated_at', 'TEXT'] +]) { + try { db.exec(`ALTER TABLE publish_log ADD COLUMN ${column} ${type}`); } catch {} +} +try { db.exec(`CREATE INDEX IF NOT EXISTS idx_publish_log_external ON publish_log(platform, external_id)`); } catch {} + +// --- Operations Center 1.30.0 --- +try { + db.exec(`CREATE TABLE IF NOT EXISTS operation_backups ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'manual', + label TEXT, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + status TEXT NOT NULL DEFAULT 'created', + verified_at TEXT, + options_json TEXT NOT NULL DEFAULT '{}', + manifest_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_operation_backups_created ON operation_backups(created_at DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_operation_backups_kind ON operation_backups(kind, created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS operation_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS update_operations ( + id TEXT PRIMARY KEY, + version_from TEXT NOT NULL, + version_to TEXT NOT NULL, + source_name TEXT, + archive_path TEXT NOT NULL, + sha256 TEXT, + status TEXT NOT NULL DEFAULT 'staged', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + applied_at TEXT, + error_message TEXT + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_update_operations_created ON update_operations(created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS system_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + migration_key TEXT NOT NULL UNIQUE, + app_version TEXT NOT NULL, + description TEXT, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS extension_clients ( + client_id TEXT PRIMARY KEY, + browser TEXT, + version TEXT, + platform TEXT, + server_url TEXT, + user_agent TEXT, + capabilities_json TEXT NOT NULL DEFAULT '{}', + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_extension_clients_seen ON extension_clients(last_seen DESC)`); + + db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) + VALUES ('operations-center-1.30.0', '1.30.0', 'Backup, Restore, Update Center und Extension-Diagnose')`).run(); +} catch {} diff --git a/lib/ebay-api.mjs b/lib/ebay-api.mjs new file mode 100644 index 0000000..f14d2fd --- /dev/null +++ b/lib/ebay-api.mjs @@ -0,0 +1,304 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const EBAY_AUTH_SANDBOX = 'https://auth.sandbox.ebay.com/oauth2/authorize'; +const EBAY_AUTH_PROD = 'https://auth.ebay.com/oauth2/authorize'; +const EBAY_TOKEN_SANDBOX = 'https://api.sandbox.ebay.com/identity/v1/oauth2/token'; +const EBAY_TOKEN_PROD = 'https://api.ebay.com/identity/v1/oauth2/token'; +const EBAY_API_SANDBOX = 'https://api.sandbox.ebay.com'; +const EBAY_API_PROD = 'https://api.ebay.com'; + +function isSandbox() { + return process.env.EBAY_SANDBOX === 'true'; +} + +function authUrl() { return isSandbox() ? EBAY_AUTH_SANDBOX : EBAY_AUTH_PROD; } +function tokenUrl() { return isSandbox() ? EBAY_TOKEN_SANDBOX : EBAY_TOKEN_PROD; } +function apiBase() { return isSandbox() ? EBAY_API_SANDBOX : EBAY_API_PROD; } + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function getBasicAuth() { + const clientId = process.env.EBAY_CLIENT_ID; + const clientSecret = process.env.EBAY_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new Error('EBAY_CLIENT_ID oder EBAY_CLIENT_SECRET nicht gesetzt'); + return Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); +} + +const SCOPES = [ + 'https://api.ebay.com/oauth/api_scope', + 'https://api.ebay.com/oauth/api_scope/sell.inventory', + 'https://api.ebay.com/oauth/api_scope/sell.account', + 'https://api.ebay.com/oauth/api_scope/sell.fulfillment', + 'https://api.ebay.com/oauth/api_scope/sell.marketing', +].join(' '); + +let oauthState = null; + +export function getOAuthUrl(redirectUri) { + if (!process.env.EBAY_CLIENT_ID) throw new Error('EBAY_CLIENT_ID nicht gesetzt'); + oauthState = Math.random().toString(36).substring(2); + + const params = new URLSearchParams({ + client_id: process.env.EBAY_CLIENT_ID, + response_type: 'code', + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + scope: SCOPES, + state: oauthState, + }); + return `${authUrl()}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + if (oauthState && state !== oauthState) throw new Error('Ungültiger OAuth-State'); + oauthState = null; + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: process.env.EBAY_RU_NAME || redirectUri, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const refreshTkn = process.env.EBAY_REFRESH_TOKEN; + if (!refreshTkn) throw new Error('Kein eBay Refresh-Token'); + + const res = await fetch(tokenUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${getBasicAuth()}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshTkn, + scope: SCOPES, + }), + }); + + if (!res.ok) throw new Error('eBay Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('EBAY_ACCESS_TOKEN', tokens.access_token); + if (tokens.refresh_token) setEnvVar('EBAY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function ebayFetch(path, opts = {}) { + let token = process.env.EBAY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit eBay verbunden'); + + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Content-Language': 'de-DE', + 'X-EBAY-C-MARKETPLACE-ID': 'EBAY_DE', + ...opts.headers, + }; + + let res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${apiBase()}${path}`, { ...opts, headers }); + } + + if (res.status === 204) return null; + + if (!res.ok) { + const err = await res.text(); + throw new Error(`eBay API (${res.status}): ${err}`); + } + + const ct = res.headers.get('content-type') || ''; + return ct.includes('json') ? res.json() : res.text(); +} + +function mapCondition(condition) { + const map = { + 'neu': 'NEW', 'new': 'NEW', + 'wie neu': 'LIKE_NEW', 'like new': 'LIKE_NEW', + 'sehr gut': 'VERY_GOOD', 'very good': 'VERY_GOOD', + 'gut': 'GOOD', 'good': 'GOOD', + 'akzeptabel': 'ACCEPTABLE', 'acceptable': 'ACCEPTABLE', + 'defekt': 'FOR_PARTS_OR_NOT_WORKING', + }; + return map[(condition || '').toLowerCase()] || 'GOOD'; +} + +export async function createInventoryItem(listing, sku) { + const images = (listing.photos || []).map(p => `http://localhost:${process.env.PORT || 8124}/uploads/${p}`); + + const item = { + availability: { + shipToLocationAvailability: { quantity: 1 }, + }, + condition: mapCondition(listing.condition), + product: { + title: listing.title, + description: listing.description || '', + aspects: {}, + imageUrls: images, + }, + }; + + if (listing.brand) item.product.aspects['Marke'] = [listing.brand]; + if (listing.color) item.product.aspects['Farbe'] = [listing.color]; + if (listing.size) item.product.aspects['Größe'] = [listing.size]; + + await ebayFetch(`/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, { + method: 'PUT', + body: JSON.stringify(item), + }); + + return sku; +} + +export async function createOffer(listing, sku) { + const price = listing.price || listing.suggested_price || 1; + + const offer = { + sku, + marketplaceId: 'EBAY_DE', + format: 'FIXED_PRICE', + listingDescription: listing.description || '', + availableQuantity: 1, + pricingSummary: { + price: { value: String(price), currency: 'EUR' }, + }, + listingPolicies: {}, + merchantLocationKey: process.env.EBAY_LOCATION_KEY || undefined, + }; + + const categoryId = listing.ebay_category_id || process.env.EBAY_DEFAULT_CATEGORY || '175673'; + offer.categoryId = categoryId; + + const fulfillmentId = process.env.EBAY_FULFILLMENT_POLICY_ID; + const paymentId = process.env.EBAY_PAYMENT_POLICY_ID; + const returnId = process.env.EBAY_RETURN_POLICY_ID; + + if (fulfillmentId) offer.listingPolicies.fulfillmentPolicyId = fulfillmentId; + if (paymentId) offer.listingPolicies.paymentPolicyId = paymentId; + if (returnId) offer.listingPolicies.returnPolicyId = returnId; + + const result = await ebayFetch('/sell/inventory/v1/offer', { + method: 'POST', + body: JSON.stringify(offer), + }); + + return result; +} + +export async function publishOffer(offerId) { + const result = await ebayFetch(`/sell/inventory/v1/offer/${offerId}/publish`, { + method: 'POST', + }); + return result; +} + +export async function createAndPublish(listing, sku) { + await createInventoryItem(listing, sku); + const offer = await createOffer(listing, sku); + const offerId = offer.offerId; + const published = await publishOffer(offerId); + return { + offerId, + listingId: published.listingId, + url: `https://www.ebay.de/itm/${published.listingId}`, + }; +} + +export async function getFulfillmentPolicies() { + return ebayFetch('/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_DE'); +} + +export async function getPaymentPolicies() { + return ebayFetch('/sell/account/v1/payment_policy?marketplace_id=EBAY_DE'); +} + +export async function getReturnPolicies() { + return ebayFetch('/sell/account/v1/return_policy?marketplace_id=EBAY_DE'); +} + +export async function getPolicies() { + const [fulfillment, payment, returns] = await Promise.all([ + getFulfillmentPolicies().catch(() => ({ fulfillmentPolicies: [] })), + getPaymentPolicies().catch(() => ({ paymentPolicies: [] })), + getReturnPolicies().catch(() => ({ returnPolicies: [] })), + ]); + return { + fulfillment: fulfillment.fulfillmentPolicies || [], + payment: payment.paymentPolicies || [], + returns: returns.returnPolicies || [], + }; +} + +export function isConnected() { + return !!(process.env.EBAY_ACCESS_TOKEN && process.env.EBAY_CLIENT_ID); +} + +export function hasCredentials() { + return !!(process.env.EBAY_CLIENT_ID && process.env.EBAY_CLIENT_SECRET); +} + +export async function getOffer(offerId) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + return ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}`); +} + +export async function updateOffer(listing, offerId, sku) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + await createInventoryItem(listing, sku); + const price = listing.price || listing.suggested_price || 1; + const body = { + sku, + marketplaceId: 'EBAY_DE', + format: 'FIXED_PRICE', + listingDescription: listing.description || '', + availableQuantity: 1, + pricingSummary: { price: { value: String(price), currency: 'EUR' } }, + listingPolicies: {}, + categoryId: listing.ebay_category_id || process.env.EBAY_DEFAULT_CATEGORY || '175673', + merchantLocationKey: process.env.EBAY_LOCATION_KEY || undefined, + }; + if (process.env.EBAY_FULFILLMENT_POLICY_ID) body.listingPolicies.fulfillmentPolicyId = process.env.EBAY_FULFILLMENT_POLICY_ID; + if (process.env.EBAY_PAYMENT_POLICY_ID) body.listingPolicies.paymentPolicyId = process.env.EBAY_PAYMENT_POLICY_ID; + if (process.env.EBAY_RETURN_POLICY_ID) body.listingPolicies.returnPolicyId = process.env.EBAY_RETURN_POLICY_ID; + await ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}`, { method: 'PUT', body: JSON.stringify(body) }); + return getOffer(offerId); +} + +export async function withdrawOffer(offerId) { + if (!offerId) throw new Error('eBay Offer-ID fehlt'); + return ebayFetch(`/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`, { method: 'POST' }); +} diff --git a/lib/etsy-api.mjs b/lib/etsy-api.mjs new file mode 100644 index 0000000..5b83247 --- /dev/null +++ b/lib/etsy-api.mjs @@ -0,0 +1,189 @@ +import { randomBytes, createHash } from 'crypto'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); + +const ETSY_BASE = 'https://api.etsy.com/v3'; +const ETSY_AUTH = 'https://www.etsy.com/oauth/connect'; +const ETSY_TOKEN = 'https://api.etsy.com/v3/public/oauth/token'; + +let pkceStore = {}; + +function setEnvVar(key, value) { + let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + const regex = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}=${value}`; + if (regex.test(content)) content = content.replace(regex, line); + else content = content.trimEnd() + '\n' + line + '\n'; + writeFileSync(envPath, content, 'utf-8'); + process.env[key] = value; +} + +function generatePKCE() { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +export function getAuthUrl(redirectUri) { + const apiKey = process.env.ETSY_API_KEY; + if (!apiKey) throw new Error('ETSY_API_KEY nicht gesetzt'); + + const { verifier, challenge } = generatePKCE(); + const state = randomBytes(16).toString('hex'); + pkceStore[state] = verifier; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: apiKey, + redirect_uri: redirectUri, + scope: 'listings_w listings_r shops_r images_w', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + return `${ETSY_AUTH}?${params}`; +} + +export async function handleCallback(code, state, redirectUri) { + const verifier = pkceStore[state]; + if (!verifier) throw new Error('Ungültiger OAuth-State'); + delete pkceStore[state]; + + const apiKey = process.env.ETSY_API_KEY; + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: apiKey, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy Token-Fehler: ${err}`); + } + + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens; +} + +async function refreshToken() { + const apiKey = process.env.ETSY_API_KEY; + const refreshToken = process.env.ETSY_REFRESH_TOKEN; + if (!refreshToken) throw new Error('Kein Etsy Refresh-Token'); + + const res = await fetch(ETSY_TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: apiKey, + refresh_token: refreshToken, + }), + }); + + if (!res.ok) throw new Error('Etsy Token-Refresh fehlgeschlagen'); + const tokens = await res.json(); + setEnvVar('ETSY_ACCESS_TOKEN', tokens.access_token); + setEnvVar('ETSY_REFRESH_TOKEN', tokens.refresh_token); + return tokens.access_token; +} + +async function etsyFetch(path, opts = {}) { + let token = process.env.ETSY_ACCESS_TOKEN; + if (!token) throw new Error('Nicht mit Etsy verbunden'); + + const apiKey = process.env.ETSY_API_KEY; + const headers = { 'x-api-key': apiKey, Authorization: `Bearer ${token}`, ...opts.headers }; + let res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + + if (res.status === 401) { + token = await refreshToken(); + headers.Authorization = `Bearer ${token}`; + res = await fetch(`${ETSY_BASE}${path}`, { ...opts, headers }); + } + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Etsy API Fehler (${res.status}): ${err}`); + } + return res.json(); +} + +export async function getShopId() { + const data = await etsyFetch('/application/users/me'); + const userId = data.user_id; + const shop = await etsyFetch(`/application/users/${userId}/shops`); + if (!shop.results?.length) throw new Error('Kein Etsy-Shop gefunden'); + return shop.results[0].shop_id; +} + +export async function createEtsyListing(listing, shopId) { + const tags = Array.isArray(listing.tags) ? listing.tags.slice(0, 13) : []; + const price = listing.price || listing.suggested_price || 10; + + const body = { + quantity: 1, + title: listing.title, + description: listing.description, + price: price, + who_made: 'someone_else', + when_made: '2020_2025', + taxonomy_id: 1, + tags, + type: 'physical', + shipping_profile_id: null, + state: 'draft', + }; + + const result = await etsyFetch(`/application/shops/${shopId}/listings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return result; +} + +export async function uploadEtsyImage(shopId, listingId, imageBuffer, filename) { + const formData = new FormData(); + formData.append('image', new Blob([imageBuffer]), filename); + + const token = process.env.ETSY_ACCESS_TOKEN; + const apiKey = process.env.ETSY_API_KEY; + + const res = await fetch(`${ETSY_BASE}/application/shops/${shopId}/listings/${listingId}/images`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Bild-Upload fehlgeschlagen: ${err}`); + } + return res.json(); +} + +export function isConnected() { + return !!(process.env.ETSY_ACCESS_TOKEN && process.env.ETSY_API_KEY); +} + +export async function getEtsyListing(listingId) { + if (!listingId) throw new Error('Etsy Listing-ID fehlt'); + return etsyFetch(`/application/listings/${encodeURIComponent(listingId)}`); +} diff --git a/lib/fees.mjs b/lib/fees.mjs new file mode 100644 index 0000000..0350747 --- /dev/null +++ b/lib/fees.mjs @@ -0,0 +1,82 @@ +const roundMoney = value => Math.round((Number(value) || 0) * 100) / 100; + +const PLATFORM_FEES = { + vinted: { + name: 'Vinted', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsgebühr für Verkäufer. Den Käuferschutz zahlt der Käufer zusätzlich.', + note: 'Optionale Hervorhebungen oder andere Zusatzdienste sind nicht enthalten.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + 'ebay-ka': { + name: 'Kleinanzeigen', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsprovision beim normalen Privatverkauf.', + note: 'Kosten können bei Zusatzoptionen, überschrittenen Anzeigenlimits oder bestimmten Fahrzeug-/Spezialkategorien entstehen.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + 'ebay-de': { + name: 'eBay.de', + calculate(price) { + const gross = roundMoney(price); + return { + gross, + fees: 0, + net: gross, + breakdown: '0 € Verkaufsprovision für private Verkäufer bei Verkäufen innerhalb Deutschlands.', + note: 'Gebühren für Zusatzoptionen und gegebenenfalls internationale Verkäufe sind nicht enthalten.', + seller_type: 'privat', + verified_at: '2026-07-08', + }; + }, + }, + etsy: { + name: 'Etsy', + calculate(price) { + const gross = roundMoney(price); + const transaction = gross * 0.065; + const processing = gross * 0.04 + 0.30; + const fees = roundMoney(transaction + processing); + return { + gross, + fees, + net: roundMoney(gross - fees), + breakdown: '6,5 % Transaktionsgebühr + 4 % + 0,30 € Etsy Payments.', + note: 'Zusätzlich 0,20 USD Einstellgebühr je Artikel/Erneuerung; mögliche USt., Währungsumrechnung und Werbegebühren sind nicht eingerechnet.', + seller_type: 'shop', + verified_at: '2026-07-08', + }; + }, + }, +}; + +export function calculateFees(platform, price) { + const calc = PLATFORM_FEES[platform]; + if (!calc) { + const gross = roundMoney(price); + return { gross, fees: 0, net: gross, breakdown: 'Unbekannte Plattform', note: '', seller_type: 'unknown' }; + } + return calc.calculate(price); +} + +export function getAllFees(price) { + const result = {}; + for (const [id, calc] of Object.entries(PLATFORM_FEES)) result[id] = calc.calculate(price); + return result; +} diff --git a/lib/html-templates.mjs b/lib/html-templates.mjs new file mode 100644 index 0000000..5807ccb --- /dev/null +++ b/lib/html-templates.mjs @@ -0,0 +1,163 @@ +const TEMPLATES = { + vinted: { + name: 'Vinted Classic', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags.join(' ') : listing.tags || ''; + return `
                                                                      +

                                                                      ${richDescription(listing)}

                                                                      + ${listing.brand || listing.size || listing.color ? ` + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                                                                      Marke${esc(listing.brand)}
                                                                      Kategorie${esc(listing.category)}
                                                                      Größe${esc(listing.size)}
                                                                      Farbe${esc(listing.color)}
                                                                      Zustand${esc(listing.condition)}
                                                                      ` : ''} + ${tags ? `

                                                                      ${esc(tags)}

                                                                      ` : ''} +
                                                                      `; + }, + }, + + 'ebay-ka': { + name: 'Kleinanzeigen Modern', + wrap(listing) { + return `
                                                                      +

                                                                      ${richDescription(listing)}

                                                                      + ${listing.brand || listing.size ? `
                                                                      +

                                                                      Artikeldetails

                                                                      +
                                                                      + ${listing.brand ? `
                                                                      Marke: ${esc(listing.brand)}
                                                                      ` : ''} + ${listing.category ? `
                                                                      Kategorie: ${esc(listing.category)}
                                                                      ` : ''} + ${listing.size ? `
                                                                      Größe: ${esc(listing.size)}
                                                                      ` : ''} + ${listing.color ? `
                                                                      Farbe: ${esc(listing.color)}
                                                                      ` : ''} + ${listing.condition ? `
                                                                      Zustand: ${esc(listing.condition)}
                                                                      ` : ''} +
                                                                      +
                                                                      ` : ''} +
                                                                      `; + }, + }, + + 'ebay-de': { + name: 'eBay Professional', + wrap(listing) { + return `
                                                                      +
                                                                      +

                                                                      ${esc(listing.title)}

                                                                      +
                                                                      +

                                                                      ${richDescription(listing)}

                                                                      + ${listing.brand || listing.size ? `
                                                                      +
                                                                      Artikelmerkmale
                                                                      + + ${listing.brand ? `` : ''} + ${listing.category ? `` : ''} + ${listing.size ? `` : ''} + ${listing.color ? `` : ''} + ${listing.condition ? `` : ''} +
                                                                      Marke${esc(listing.brand)}
                                                                      Typ${esc(listing.category)}
                                                                      Größe${esc(listing.size)}
                                                                      Farbe${esc(listing.color)}
                                                                      Zustand${esc(listing.condition)}
                                                                      +
                                                                      ` : ''} +

                                                                      Privatverkauf — keine Garantie, keine Rücknahme.

                                                                      +
                                                                      `; + }, + }, + + etsy: { + name: 'Etsy Handmade', + wrap(listing) { + const tags = Array.isArray(listing.tags) ? listing.tags : []; + return `
                                                                      +

                                                                      ${richDescription(listing)}

                                                                      + ${listing.brand || listing.size ? `
                                                                      +

                                                                      Details

                                                                      +
                                                                      + ${listing.brand ? `
                                                                      Brand: ${esc(listing.brand)}
                                                                      ` : ''} + ${listing.category ? `
                                                                      Category: ${esc(listing.category)}
                                                                      ` : ''} + ${listing.size ? `
                                                                      Size: ${esc(listing.size)}
                                                                      ` : ''} + ${listing.color ? `
                                                                      Color: ${esc(listing.color)}
                                                                      ` : ''} + ${listing.condition ? `
                                                                      Condition: ${esc(listing.condition)}
                                                                      ` : ''} +
                                                                      +
                                                                      ` : ''} + ${tags.length ? `

                                                                      ${tags.map(t => `${esc(t)}`).join(' ')}

                                                                      ` : ''} +
                                                                      `; + }, + }, +}; + +function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function nl2br(s) { return esc(s || '').replace(/\n/g, '
                                                                      '); } + +const ALLOWED_RICH_TAGS = new Set([ + 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'ol', 'ul', 'li', + 'blockquote', 'a', 'h1', 'h2', 'h3', 'span', 'div', 'sub', 'sup', 'code', 'pre', +]); +const VOID_RICH_TAGS = new Set(['br']); + +function sanitizeStyle(style) { + const safe = []; + for (const declaration of String(style || '').split(';')) { + const [rawName, ...rest] = declaration.split(':'); + const name = String(rawName || '').trim().toLowerCase(); + const value = rest.join(':').trim(); + if (!value) continue; + if (!['color', 'background-color', 'text-align'].includes(name)) continue; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) continue; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) continue; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) continue; + safe.push(`${name}:${value}`); + } + return safe.join(';'); +} + +export function sanitizeRichHtml(input) { + let html = String(input || ''); + html = html + .replace(//g, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(/<(script|style|iframe|object|embed|form|input|button|textarea|select|option|svg|math|video|audio)\b[^>]*\/?\s*>/gi, ''); + + return html.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (full, rawTag, rawAttrs) => { + const tag = String(rawTag || '').toLowerCase(); + const closing = /^<\//.test(full); + if (!ALLOWED_RICH_TAGS.has(tag)) return ''; + if (closing) return VOID_RICH_TAGS.has(tag) ? '' : ``; + + const attrs = []; + const source = String(rawAttrs || ''); + source.replace(/([a-z0-9:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi, (_m, rawName, dq, sq, bare) => { + const name = String(rawName || '').toLowerCase(); + const value = dq ?? sq ?? bare ?? ''; + if (name.startsWith('on')) return ''; + if (name === 'href' && tag === 'a') { + const href = String(value).trim(); + if (/^(https?:|mailto:|tel:|#|\/)/i.test(href)) attrs.push(`href="${esc(href)}"`); + } else if (name === 'target' && tag === 'a' && ['_blank', '_self'].includes(value)) { + attrs.push(`target="${value}"`); + } else if (name === 'rel' && tag === 'a') { + attrs.push('rel="noopener noreferrer"'); + } else if (name === 'style') { + const safeStyle = sanitizeStyle(value); + if (safeStyle) attrs.push(`style="${esc(safeStyle)}"`); + } else if (name === 'class' && /^ql-(align|indent|size|font|color|background)-[a-z0-9-]+$/i.test(value)) { + attrs.push(`class="${esc(value)}"`); + } + return ''; + }); + if (tag === 'a' && attrs.some(attr => attr.startsWith('target="_blank"')) && !attrs.some(attr => attr.startsWith('rel='))) { + attrs.push('rel="noopener noreferrer"'); + } + return `<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''}>`; + }); +} + +function richDescription(listing) { + const html = sanitizeRichHtml(listing?.description_html || ''); + return html || nl2br(listing?.description || ''); +} + +export function wrapInTemplate(listing, platformId) { + const tpl = TEMPLATES[platformId]; + if (!tpl) return `

                                                                      ${richDescription(listing)}

                                                                      `; + return tpl.wrap(listing); +} + +export function getHtmlTemplates() { + return Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })); +} diff --git a/lib/image-batch-jobs.mjs b/lib/image-batch-jobs.mjs new file mode 100644 index 0000000..662b7b7 --- /dev/null +++ b/lib/image-batch-jobs.mjs @@ -0,0 +1,344 @@ +import { randomUUID } from 'crypto'; +import { basename, dirname, extname, resolve } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; +import { db, createImageEditVersion, getImageEditRoot } from './db.mjs'; +import { renderImageVersion, resolveEditableImage } from './image-editor.mjs'; + +const uploadRoot = resolve(UPLOADS_DIR); +const TERMINAL_ITEM = new Set(['completed', 'failed', 'cancelled']); +const TERMINAL_JOB = new Set(['completed', 'partial', 'failed', 'cancelled']); +let loopTimer = null; +let processing = false; + +function jsonParse(value, fallback = {}) { + try { return JSON.parse(value || ''); } catch { return fallback; } +} + +function normalizePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +function clamp(value, min, max, fallback = min) { + const number = Number(value); + return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback; +} + +function slug(value, fallback = 'bild') { + const clean = String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); + return clean || fallback; +} + +function parseProfile(row) { + if (!row) return null; + return { + ...row, + is_builtin: Number(row.is_builtin || 0) === 1, + operations: jsonParse(row.operations_json, {}), + }; +} + +function parseItem(row) { + if (!row) return null; + return { ...row, settings: jsonParse(row.settings_json, {}) }; +} + +function parseJob(row, { includeItems = false } = {}) { + if (!row) return null; + const job = { + ...row, + pause_requested: Number(row.pause_requested || 0) === 1, + cancel_requested: Number(row.cancel_requested || 0) === 1, + settings: jsonParse(row.settings_json, {}), + }; + if (includeItems) job.items = db.prepare('SELECT * FROM image_batch_job_items WHERE job_id = ? ORDER BY created_at, id').all(job.id).map(parseItem); + return job; +} + +export function listImageBatchProfiles() { + return db.prepare('SELECT * FROM image_batch_profiles ORDER BY is_builtin DESC, name COLLATE NOCASE').all().map(parseProfile); +} + +export function createImageBatchProfile(data = {}) { + const name = String(data.name || '').trim().slice(0, 80); + if (!name) throw new Error('Profilname fehlt.'); + const description = String(data.description || '').trim().slice(0, 300); + const operations = data.operations && typeof data.operations === 'object' ? data.operations : {}; + const filenamePattern = String(data.filename_pattern || '{name}-{profile}-{index}').trim().slice(0, 120) || '{name}-{profile}-{index}'; + const key = `custom-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const result = db.prepare(`INSERT INTO image_batch_profiles + (key, name, description, operations_json, filename_pattern, is_builtin) VALUES (?, ?, ?, ?, ?, 0)`) + .run(key, name, description, JSON.stringify(operations), filenamePattern); + return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(result.lastInsertRowid)); +} + +export function updateImageBatchProfile(id, data = {}) { + const current = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); + if (!current) throw new Error('Produktionsprofil wurde nicht gefunden.'); + const name = String(data.name ?? current.name).trim().slice(0, 80); + if (!name) throw new Error('Profilname fehlt.'); + const description = String(data.description ?? current.description ?? '').trim().slice(0, 300); + const operations = data.operations && typeof data.operations === 'object' ? data.operations : current.operations; + const filenamePattern = String(data.filename_pattern ?? current.filename_pattern ?? '{name}-{profile}-{index}').trim().slice(0, 120); + db.prepare(`UPDATE image_batch_profiles SET name = ?, description = ?, operations_json = ?, filename_pattern = ?, updated_at = datetime('now') WHERE id = ?`) + .run(name, description, JSON.stringify(operations), filenamePattern, Number(id)); + return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); +} + +export function deleteImageBatchProfile(id) { + const profile = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id))); + if (!profile) return { ok: true, deleted: 0 }; + if (profile.is_builtin) throw new Error('Integrierte Produktionsprofile können nicht gelöscht werden.'); + const inUse = Number(db.prepare("SELECT COUNT(*) AS count FROM image_batch_job_items WHERE profile_id = ? AND status IN ('queued','running')").get(Number(id))?.count || 0); + if (inUse) throw new Error('Das Profil wird noch von einem laufenden Auftrag verwendet.'); + const result = db.prepare('DELETE FROM image_batch_profiles WHERE id = ?').run(Number(id)); + return { ok: true, deleted: result.changes }; +} + +function mergeOperations(profileOperations = {}, overrides = {}) { + const operations = { ...profileOperations }; + operations.resize = { ...(profileOperations.resize || {}) }; + operations.watermark = { ...(profileOperations.watermark || {}) }; + if (overrides.remove_background === true) { + operations.remove_background = true; + operations.background_sensitivity = Math.round(clamp(overrides.background_sensitivity, 0, 100, 45)); + if (!overrides.format) operations.format = 'png'; + } + if (overrides.watermark?.enabled) { + operations.watermark = { + enabled: true, + text: String(overrides.watermark.text || 'Vendoo').slice(0, 80), + position: String(overrides.watermark.position || 'bottom-right'), + opacity: Math.round(clamp(overrides.watermark.opacity, 10, 100, 40)), + }; + } + if (overrides.format) operations.format = String(overrides.format); + if (Number.isFinite(Number(overrides.quality))) operations.quality = Math.round(clamp(overrides.quality, 40, 100, 90)); + return operations; +} + +function filenameFromPattern(pattern, sourcePath, profile, index) { + const sourceName = basename(sourcePath, extname(sourcePath)); + return slug(String(pattern || '{name}-{profile}-{index}') + .replaceAll('{name}', slug(sourceName)) + .replaceAll('{profile}', slug(profile.key || profile.name)) + .replaceAll('{index}', String(index).padStart(2, '0')), + `vendoo-${index}`); +} + +export function createImageBatchJob(data = {}) { + const sourcePaths = [...new Set((Array.isArray(data.source_paths) ? data.source_paths : []).map(normalizePath).filter(Boolean))].slice(0, 200); + const profileIds = [...new Set((Array.isArray(data.profile_ids) ? data.profile_ids : []).map(Number).filter(Number.isFinite))].slice(0, 20); + if (!sourcePaths.length) throw new Error('Mindestens ein Quellbild auswählen.'); + if (!profileIds.length) throw new Error('Mindestens ein Produktionsprofil auswählen.'); + for (const sourcePath of sourcePaths) resolveEditableImage(uploadRoot, sourcePath); + const placeholders = profileIds.map(() => '?').join(','); + const profiles = db.prepare(`SELECT * FROM image_batch_profiles WHERE id IN (${placeholders}) ORDER BY is_builtin DESC, name`).all(...profileIds).map(parseProfile); + if (profiles.length !== profileIds.length) throw new Error('Mindestens ein Produktionsprofil wurde nicht gefunden.'); + + const id = randomUUID(); + const name = String(data.name || `Bildproduktion ${new Date().toLocaleDateString('de-DE')}`).trim().slice(0, 100); + const overrides = data.overrides && typeof data.overrides === 'object' ? data.overrides : {}; + const settings = { source_paths: sourcePaths, profile_ids: profileIds, overrides }; + const totalItems = sourcePaths.length * profiles.length; + const insertJob = db.prepare(`INSERT INTO image_batch_jobs + (id, name, source_count, profile_count, total_items, settings_json) VALUES (?, ?, ?, ?, ?, ?)`); + const insertItem = db.prepare(`INSERT INTO image_batch_job_items + (id, job_id, source_path, profile_id, profile_key, profile_name, settings_json) VALUES (?, ?, ?, ?, ?, ?, ?)`); + const transaction = db.transaction(() => { + insertJob.run(id, name, sourcePaths.length, profiles.length, totalItems, JSON.stringify(settings)); + let itemIndex = 0; + for (const sourcePath of sourcePaths) { + for (const profile of profiles) { + itemIndex += 1; + const operations = mergeOperations(profile.operations, overrides); + const itemSettings = { + operations, + filename_pattern: profile.filename_pattern, + output_filename: filenameFromPattern(profile.filename_pattern, sourcePath, profile, itemIndex), + }; + insertItem.run(randomUUID(), id, sourcePath, profile.id, profile.key, profile.name, JSON.stringify(itemSettings)); + } + } + }); + transaction(); + scheduleLoop(20); + return getImageBatchJob(id); +} + +export function getImageBatchJob(id) { + return parseJob(db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(String(id || '')), { includeItems: true }); +} + +export function paginateImageBatchJobs({ page = 1, pageSize = 10, status = '' } = {}) { + const safeSize = Math.max(5, Math.min(15, Number(pageSize) || 10)); + const requestedPage = Math.max(1, Number(page) || 1); + const statuses = String(status || '').split(',').map(value => value.trim()).filter(Boolean); + const where = statuses.length ? `WHERE status IN (${statuses.map(() => '?').join(',')})` : ''; + const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM image_batch_jobs ${where}`).get(...statuses)?.count || 0); + const totalPages = Math.max(1, Math.ceil(total / safeSize)); + const safePage = Math.min(requestedPage, totalPages); + const rows = db.prepare(`SELECT * FROM image_batch_jobs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`) + .all(...statuses, safeSize, (safePage - 1) * safeSize).map(row => parseJob(row)); + return { items: rows, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary: getImageBatchSummary() }; +} + +export function getImageBatchSummary() { + const rows = db.prepare('SELECT status, COUNT(*) AS count FROM image_batch_jobs GROUP BY status').all(); + const summary = { total: 0, queued: 0, running: 0, paused: 0, completed: 0, partial: 0, failed: 0, cancelled: 0 }; + for (const row of rows) { summary[row.status] = Number(row.count || 0); summary.total += Number(row.count || 0); } + return summary; +} + +function refreshJob(jobId) { + const counts = db.prepare(`SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, + SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued + FROM image_batch_job_items WHERE job_id = ?`).get(jobId); + const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(jobId); + if (!job) return null; + let status = job.status; + let phase = job.phase; + let completedAt = job.completed_at; + if (Number(job.cancel_requested || 0) === 1 && Number(counts.running || 0) === 0) { + status = 'cancelled'; phase = 'cancelled'; completedAt = completedAt || new Date().toISOString(); + } else if (Number(job.pause_requested || 0) === 1 && Number(counts.running || 0) === 0) { + status = 'paused'; phase = 'paused'; + } else if (Number(counts.running || 0) > 0) { + status = 'running'; phase = 'processing'; + } else if (Number(counts.queued || 0) > 0) { + status = 'queued'; phase = 'queued'; + } else if (Number(counts.total || 0) > 0) { + const failed = Number(counts.failed || 0); + const completed = Number(counts.completed || 0); + const cancelled = Number(counts.cancelled || 0); + if (completed === Number(counts.total)) status = 'completed'; + else if (completed > 0 && (failed > 0 || cancelled > 0)) status = 'partial'; + else if (failed > 0) status = 'failed'; + else status = 'cancelled'; + phase = status; completedAt = completedAt || new Date().toISOString(); + } + db.prepare(`UPDATE image_batch_jobs SET status = ?, phase = ?, completed_items = ?, failed_items = ?, cancelled_items = ?, completed_at = ?, updated_at = datetime('now') WHERE id = ?`) + .run(status, phase, Number(counts.completed || 0), Number(counts.failed || 0), Number(counts.cancelled || 0), completedAt, jobId); + return getImageBatchJob(jobId); +} + +export function pauseImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status)) return job; + db.prepare("UPDATE image_batch_jobs SET pause_requested = 1, status = CASE WHEN status = 'running' THEN status ELSE 'paused' END, phase = 'pausing', updated_at = datetime('now') WHERE id = ?").run(job.id); + return refreshJob(job.id); +} + +export function resumeImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status) && !['failed', 'partial'].includes(job.status)) return job; + db.prepare("UPDATE image_batch_jobs SET pause_requested = 0, cancel_requested = 0, status = 'queued', phase = 'queued', completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id); + scheduleLoop(20); + return refreshJob(job.id); +} + +export function cancelImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + if (TERMINAL_JOB.has(job.status)) return job; + const tx = db.transaction(() => { + db.prepare("UPDATE image_batch_jobs SET cancel_requested = 1, pause_requested = 0, phase = 'cancelling', updated_at = datetime('now') WHERE id = ?").run(job.id); + db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(job.id); + }); + tx(); + return refreshJob(job.id); +} + +export function retryImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) throw new Error('Bildauftrag wurde nicht gefunden.'); + const result = db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE job_id = ? AND status IN ('failed','cancelled')").run(job.id); + if (!result.changes) throw new Error('Dieser Auftrag enthält keine wiederholbaren Positionen.'); + db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, error_message = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id); + scheduleLoop(20); + return refreshJob(job.id); +} + +export function retryImageBatchItem(jobId, itemId) { + const item = parseItem(db.prepare('SELECT * FROM image_batch_job_items WHERE id = ? AND job_id = ?').get(String(itemId), String(jobId))); + if (!item) throw new Error('Bildposition wurde nicht gefunden.'); + if (!['failed', 'cancelled'].includes(item.status)) throw new Error('Nur fehlgeschlagene oder abgebrochene Positionen können wiederholt werden.'); + db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(item.id); + db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(jobId); + scheduleLoop(20); + return refreshJob(jobId); +} + +export function deleteImageBatchJob(id) { + const job = getImageBatchJob(id); + if (!job) return { ok: true, deleted: 0 }; + if (!TERMINAL_JOB.has(job.status)) throw new Error('Laufende Bildaufträge können nicht gelöscht werden.'); + const result = db.prepare('DELETE FROM image_batch_jobs WHERE id = ?').run(job.id); + return { ok: true, deleted: result.changes }; +} + +export function getImageBatchOutputPaths(id) { + return db.prepare("SELECT output_path FROM image_batch_job_items WHERE job_id = ? AND status = 'completed' AND output_path IS NOT NULL ORDER BY created_at, id") + .all(String(id || '')).map(row => row.output_path).filter(Boolean); +} + +async function processNextItem() { + if (processing) return; + const item = parseItem(db.prepare(`SELECT i.* FROM image_batch_job_items i + JOIN image_batch_jobs j ON j.id = i.job_id + WHERE i.status = 'queued' AND j.pause_requested = 0 AND j.cancel_requested = 0 AND j.status IN ('queued','running') + ORDER BY j.created_at ASC, i.created_at ASC LIMIT 1`).get()); + if (!item) return; + processing = true; + try { + const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(item.job_id); + db.prepare("UPDATE image_batch_jobs SET status = 'running', phase = 'processing', started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?").run(item.job_id); + db.prepare("UPDATE image_batch_job_items SET status = 'running', phase = 'rendering', started_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(item.id); + const outputDirectory = `batch/${String(item.job_id).slice(0, 8)}`; + const rootPath = getImageEditRoot(item.source_path) || item.source_path; + const result = await renderImageVersion({ + uploadRoot, + sourcePath: item.source_path, + operations: item.settings.operations || {}, + context: 'batch', + rootPath, + recordVersion: createImageEditVersion, + outputDirectory, + outputFilename: item.settings.output_filename, + }); + db.prepare("UPDATE image_batch_job_items SET status = 'completed', phase = 'completed', output_path = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?") + .run(result.filename, item.id); + } catch (error) { + db.prepare("UPDATE image_batch_job_items SET status = 'failed', phase = 'failed', error_message = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?") + .run(String(error?.message || error || 'Bild konnte nicht verarbeitet werden.').slice(0, 1000), item.id); + } finally { + refreshJob(item.job_id); + processing = false; + scheduleLoop(20); + } +} + +function scheduleLoop(delay = 500) { + if (loopTimer) clearTimeout(loopTimer); + loopTimer = setTimeout(async () => { + loopTimer = null; + await processNextItem(); + scheduleLoop(500); + }, delay); + loopTimer.unref?.(); +} + +export function startImageBatchJobLoop() { + db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', started_at = NULL, updated_at = datetime('now') WHERE status = 'running'").run(); + db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = COALESCE(completed_at, datetime('now')), updated_at = datetime('now') WHERE status = 'queued' AND job_id IN (SELECT id FROM image_batch_jobs WHERE cancel_requested = 1)").run(); + db.prepare("UPDATE image_batch_jobs SET status = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, phase = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, updated_at = datetime('now') WHERE status = 'running'").run(); + for (const row of db.prepare("SELECT id FROM image_batch_jobs WHERE status IN ('queued','running','paused','cancelled')").all()) refreshJob(row.id); + scheduleLoop(200); +} diff --git a/lib/image-editor.mjs b/lib/image-editor.mjs new file mode 100644 index 0000000..23f9b04 --- /dev/null +++ b/lib/image-editor.mjs @@ -0,0 +1,305 @@ +import sharp from 'sharp'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, statSync, unlinkSync } from 'fs'; +import { dirname, extname, join, resolve, sep } from 'path'; + +const SUPPORTED_FORMATS = new Set(['jpeg', 'webp', 'png']); +const WATERMARK_POSITIONS = new Set(['bottom-right', 'bottom-left', 'top-right', 'top-left', 'center']); +const MAX_PIXELS = 60_000_000; +const RESIZE_FITS = new Set(['inside', 'contain', 'cover']); + +function clamp(value, min, max, fallback = min) { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function normalizeRelativePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); +} + +export function resolveEditableImage(uploadRoot, relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized || normalized.includes('\0')) throw new Error('Bildpfad fehlt.'); + const root = resolve(uploadRoot); + const absolute = resolve(root, normalized); + if (absolute !== root && !absolute.startsWith(root + sep)) throw new Error('Ungültiger Bildpfad.'); + if (!existsSync(absolute) || !statSync(absolute).isFile()) throw new Error('Bilddatei wurde nicht gefunden.'); + if (!['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif'].includes(extname(absolute).toLowerCase())) { + throw new Error('Dieses Bildformat kann nicht bearbeitet werden.'); + } + return { normalized, absolute, root }; +} + +export function normalizeEditorOperations(input = {}) { + const cropInput = input.crop && typeof input.crop === 'object' ? input.crop : null; + const crop = cropInput ? { + x: clamp(cropInput.x, 0, 1, 0), + y: clamp(cropInput.y, 0, 1, 0), + width: clamp(cropInput.width, 0.01, 1, 1), + height: clamp(cropInput.height, 0.01, 1, 1), + } : null; + if (crop) { + crop.width = Math.min(crop.width, 1 - crop.x); + crop.height = Math.min(crop.height, 1 - crop.y); + } + + const resizeInput = input.resize && typeof input.resize === 'object' ? input.resize : {}; + const width = Math.round(clamp(resizeInput.width, 0, 12000, 0)); + const height = Math.round(clamp(resizeInput.height, 0, 12000, 0)); + const resizeFit = RESIZE_FITS.has(String(resizeInput.fit || '').toLowerCase()) ? String(resizeInput.fit).toLowerCase() : 'inside'; + const resizeBackground = /^#[0-9a-f]{6}$/i.test(String(resizeInput.background || '')) ? String(resizeInput.background) : '#ffffff'; + + const rotation = Math.round(clamp(input.rotation, -1080, 1080, 0) / 90) * 90; + const watermarkInput = input.watermark && typeof input.watermark === 'object' ? input.watermark : {}; + const watermarkText = String(watermarkInput.text || 'Vendoo').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); + const watermarkPosition = WATERMARK_POSITIONS.has(String(watermarkInput.position || '')) ? String(watermarkInput.position) : 'bottom-right'; + return { + rotation: ((rotation % 360) + 360) % 360, + straighten: clamp(input.straighten, -15, 15, 0), + flip_h: Boolean(input.flip_h), + flip_v: Boolean(input.flip_v), + crop, + exposure: clamp(input.exposure, -2, 2, 0), + brightness: clamp(input.brightness, -100, 100, 0), + contrast: clamp(input.contrast, -100, 100, 0), + highlights: clamp(input.highlights, -100, 100, 0), + shadows: clamp(input.shadows, -100, 100, 0), + temperature: clamp(input.temperature, -100, 100, 0), + saturation: clamp(input.saturation, -100, 100, 0), + sharpness: clamp(input.sharpness, 0, 100, 0), + remove_background: Boolean(input.remove_background), + background_sensitivity: Math.round(clamp(input.background_sensitivity, 0, 100, 45)), + watermark: { + enabled: Boolean(watermarkInput.enabled) && Boolean(watermarkText), + text: watermarkText || 'Vendoo', + position: watermarkPosition, + opacity: Math.round(clamp(watermarkInput.opacity, 10, 100, 40)), + }, + resize: { width, height, fit: resizeFit, background: resizeBackground }, + format: SUPPORTED_FORMATS.has(String(input.format || '').toLowerCase()) ? String(input.format).toLowerCase() : 'jpeg', + quality: Math.round(clamp(input.quality, 40, 100, 90)), + }; +} + +function applyToneAdjustments(data, channels, operations) { + const exposure = 2 ** operations.exposure; + const brightness = operations.brightness / 100 * 0.22; + const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100)); + const contrast = (1 + contrastValue) / (1 - contrastValue); + const highlights = operations.highlights / 100; + const shadows = operations.shadows / 100; + const temperature = operations.temperature / 100; + const saturation = 1 + operations.saturation / 100; + + for (let index = 0; index < data.length; index += channels) { + let red = data[index] / 255; + let green = data[index + 1] / 255; + let blue = data[index + 2] / 255; + + red = red * exposure + brightness; + green = green * exposure + brightness; + blue = blue * exposure + brightness; + + red = (red - 0.5) * contrast + 0.5; + green = (green - 0.5) * contrast + 0.5; + blue = (blue - 0.5) * contrast + 0.5; + + const luminanceBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722)); + const shadowWeight = (1 - luminanceBefore) ** 2; + const highlightWeight = luminanceBefore ** 2; + const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28; + const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38; + red += shadowDelta + highlightDelta; + green += shadowDelta + highlightDelta; + blue += shadowDelta + highlightDelta; + + red += temperature * 0.12; + green += temperature * 0.015; + blue -= temperature * 0.12; + + const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; + red = luminance + (red - luminance) * saturation; + green = luminance + (green - luminance) * saturation; + blue = luminance + (blue - luminance) * saturation; + + data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255); + data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255); + data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255); + } + return data; +} + +async function applyGeometry(sourcePath, operations) { + // Sharp applies only one rotation per pipeline. Keep EXIF orientation, + // flipping and the combined user rotation/straightening in separate stages. + let rendered = await sharp(sourcePath, { failOn: 'none', limitInputPixels: MAX_PIXELS }) + .rotate() + .toBuffer({ resolveWithObject: true }); + + if (operations.flip_h || operations.flip_v) { + let flipStage = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); + if (operations.flip_v) flipStage = flipStage.flip(); + if (operations.flip_h) flipStage = flipStage.flop(); + rendered = await flipStage.toBuffer({ resolveWithObject: true }); + } + + const angle = operations.rotation + operations.straighten; + if (Math.abs(angle) >= 0.05) { + rendered = await sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }) + .rotate(angle, { background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .toBuffer({ resolveWithObject: true }); + } + + let current = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); + + if (operations.crop) { + const { width, height } = rendered.info; + const left = Math.max(0, Math.min(width - 1, Math.round(operations.crop.x * width))); + const top = Math.max(0, Math.min(height - 1, Math.round(operations.crop.y * height))); + const cropWidth = Math.max(1, Math.min(width - left, Math.round(operations.crop.width * width))); + const cropHeight = Math.max(1, Math.min(height - top, Math.round(operations.crop.height * height))); + current = current.extract({ left, top, width: cropWidth, height: cropHeight }); + } + + if (operations.resize.width || operations.resize.height) { + const fit = operations.resize.fit || 'inside'; + current = current.resize({ + width: operations.resize.width || undefined, + height: operations.resize.height || undefined, + fit, + background: operations.resize.background || '#ffffff', + withoutEnlargement: fit === 'inside', + position: 'centre', + kernel: sharp.kernel.lanczos3, + }); + } + return current; +} + + +function removeUniformBackground(data, channels, width, height, sensitivity = 45) { + if (channels < 4 || !width || !height) return data; + const indexes = [0, (width - 1) * channels, ((height - 1) * width) * channels, (width * height - 1) * channels]; + const background = indexes.reduce((sum, index) => ({ + r: sum.r + data[index], + g: sum.g + data[index + 1], + b: sum.b + data[index + 2], + }), { r: 0, g: 0, b: 0 }); + background.r /= indexes.length; + background.g /= indexes.length; + background.b /= indexes.length; + const level = Math.max(0, Math.min(100, Number(sensitivity) || 0)); + const solidThreshold = 24 + level * 2.1; + const featherWidth = 45 + level * 1.2; + const featherEnd = solidThreshold + featherWidth; + for (let index = 0; index < data.length; index += channels) { + const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); + if (distance <= solidThreshold) data[index + 3] = 0; + else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth); + } + return data; +} + +function escapeXmlText(value) { + return String(value || '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); +} + +function createWatermarkSvg(width, height, watermark) { + const margin = Math.max(10, Math.round(Math.min(width, height) * 0.025)); + const fontSize = Math.max(16, Math.round(width * 0.04)); + const position = watermark.position || 'bottom-right'; + let x = width - margin; + let y = height - margin; + let anchor = 'end'; + let baseline = 'auto'; + if (position.includes('left')) { x = margin; anchor = 'start'; } + if (position.includes('top')) { y = margin + fontSize; baseline = 'auto'; } + if (position === 'center') { x = width / 2; y = height / 2; anchor = 'middle'; baseline = 'middle'; } + const opacity = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100)); + const text = escapeXmlText(watermark.text); + return Buffer.from(`${text}`); +} + +export async function renderImageVersion({ uploadRoot, sourcePath, operations: rawOperations, context = 'generator', userId = null, rootPath = null, recordVersion = null, outputDirectory = null, outputFilename = null }) { + const source = resolveEditableImage(uploadRoot, sourcePath); + const operations = normalizeEditorOperations(rawOperations); + const resolvedRootPath = String(rootPath || source.normalized).replace(/\\/g, '/').replace(/^\/+/, '').trim() || source.normalized; + + let pipeline = await applyGeometry(source.absolute, operations); + const raw = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const adjusted = applyToneAdjustments(raw.data, raw.info.channels, operations); + if (operations.remove_background) removeUniformBackground(adjusted, raw.info.channels, raw.info.width, raw.info.height, operations.background_sensitivity); + pipeline = sharp(adjusted, { + raw: { width: raw.info.width, height: raw.info.height, channels: raw.info.channels }, + limitInputPixels: MAX_PIXELS, + }); + + if (operations.sharpness > 0) { + const strength = operations.sharpness / 100; + pipeline = pipeline.sharpen({ sigma: 0.6 + strength * 1.8, m1: 0.5 + strength * 1.5, m2: 1.5 + strength * 2.5 }); + } + if (operations.watermark.enabled && operations.watermark.text) { + pipeline = pipeline.composite([{ input: createWatermarkSvg(raw.info.width, raw.info.height, operations.watermark), top: 0, left: 0 }]); + } + + const extension = operations.format === 'jpeg' ? '.jpg' : `.${operations.format}`; + const sourceFolder = dirname(source.normalized).replace(/\\/g, '/'); + const requestedFolder = normalizeRelativePath(outputDirectory || sourceFolder || '.'); + const safeFolder = requestedFolder === '.' ? '' : requestedFolder.split('/').filter(part => part && part !== '.' && part !== '..').join('/'); + const requestedBase = String(outputFilename || randomUUID()).replace(/\\/g, '-').replace(/[^a-zA-Z0-9äöüÄÖÜß._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) || randomUUID(); + const cleanBase = requestedBase.replace(/\.(jpe?g|png|webp)$/i, ''); + let outputName = `${cleanBase}${extension}`; + let outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; + let outputAbsolute = resolve(uploadRoot, outputRelative); + const resolvedUploadRoot = resolve(uploadRoot); + if (outputAbsolute === resolvedUploadRoot || !outputAbsolute.startsWith(resolvedUploadRoot + sep)) throw new Error('Ungültiger Ausgabeordner.'); + let duplicateIndex = 2; + while (existsSync(outputAbsolute)) { + outputName = `${cleanBase}-${duplicateIndex}${extension}`; + outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; + outputAbsolute = resolve(uploadRoot, outputRelative); + duplicateIndex += 1; + } + mkdirSync(dirname(outputAbsolute), { recursive: true }); + + if (operations.format === 'png') pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }); + else if (operations.format === 'webp') pipeline = pipeline.webp({ quality: operations.quality, smartSubsample: true }); + else pipeline = pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: operations.quality, mozjpeg: true, chromaSubsampling: '4:4:4' }); + + await pipeline.toFile(outputAbsolute); + const metadata = await sharp(outputAbsolute, { failOn: 'none' }).metadata(); + const stats = statSync(outputAbsolute); + const versionData = { + root_path: resolvedRootPath, + source_path: source.normalized, + output_path: outputRelative, + context: String(context || 'generator').slice(0, 40), + operations, + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || operations.format, + quality: operations.quality, + file_size: stats.size, + user_id: userId, + }; + let version; + try { + version = typeof recordVersion === 'function' ? await recordVersion(versionData) : versionData; + } catch (error) { + try { unlinkSync(outputAbsolute); } catch {} + throw error; + } + + return { + filename: outputRelative, + root_path: resolvedRootPath, + version, + metadata: { + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || operations.format, + file_size: stats.size, + }, + operations, + }; +} diff --git a/lib/images.mjs b/lib/images.mjs new file mode 100644 index 0000000..842ce99 --- /dev/null +++ b/lib/images.mjs @@ -0,0 +1,90 @@ +import sharp from 'sharp'; +import { join } from 'path'; +import { UPLOADS_DIR } from './runtime-paths.mjs'; + +const uploadsDir = UPLOADS_DIR; + +export async function prepareForAI(filenames, maxSize = 1024) { + const images = []; + for (const filename of filenames) { + const filepath = join(uploadsDir, filename); + const buffer = await sharp(filepath) + .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + images.push({ base64: buffer.toString('base64'), mediaType: 'image/jpeg' }); + } + return images; +} + +export async function removeBackground(filename) { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_nobg.png'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const { data, info } = await image + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const corners = [ + getPixel(data, 0, 0, info.width), + getPixel(data, info.width - 1, 0, info.width), + getPixel(data, 0, info.height - 1, info.width), + getPixel(data, info.width - 1, info.height - 1, info.width), + ]; + + const bgColor = avgColor(corners); + const tolerance = 60; + const result = Buffer.from(data); + + for (let i = 0; i < data.length; i += 4) { + const dr = Math.abs(data[i] - bgColor.r); + const dg = Math.abs(data[i + 1] - bgColor.g); + const db = Math.abs(data[i + 2] - bgColor.b); + if (dr + dg + db < tolerance * 3) { + result[i + 3] = 0; + } + } + + await sharp(result, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png() + .toFile(outPath); + + return newName; +} + +function getPixel(data, x, y, width) { + const idx = (y * width + x) * 4; + return { r: data[idx], g: data[idx + 1], b: data[idx + 2] }; +} + +function avgColor(pixels) { + const sum = pixels.reduce((a, p) => ({ r: a.r + p.r, g: a.g + p.g, b: a.b + p.b }), { r: 0, g: 0, b: 0 }); + const n = pixels.length; + return { r: Math.round(sum.r / n), g: Math.round(sum.g / n), b: Math.round(sum.b / n) }; +} + +export async function addWatermark(filename, text = 'Vendoo') { + const filepath = join(uploadsDir, filename); + const newName = filename.replace(/(\.\w+)$/, '_wm$1'); + const outPath = join(uploadsDir, newName); + + const image = sharp(filepath); + const { width, height } = await image.metadata(); + + const fontSize = Math.max(16, Math.round(width * 0.04)); + const svg = ` + + ${text} + `; + + await image + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .toFile(outPath); + + return newName; +} diff --git a/lib/mailer.mjs b/lib/mailer.mjs new file mode 100644 index 0000000..655893f --- /dev/null +++ b/lib/mailer.mjs @@ -0,0 +1,214 @@ +import nodemailer from 'nodemailer'; + +let transporter = null; + +export function initMailer() { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587'); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + const from = process.env.SMTP_FROM || user; + + if (!host || !user || !pass) { + console.warn('SMTP nicht konfiguriert — Magic Links werden in der Konsole ausgegeben'); + return false; + } + + transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }); + + return true; +} + +function brandedHtml(title, content, buttonText, buttonUrl) { + return ` + + + + + +
                                                                      + + + + +
                                                                      +

                                                                      Vendoo

                                                                      +
                                                                      +

                                                                      ${title}

                                                                      +
                                                                      ${content}
                                                                      + ${buttonUrl ? ` + +

                                                                      Oder kopiere diesen Link:
                                                                      + ${buttonUrl}

                                                                      ` : ''} +
                                                                      +

                                                                      Vendoo — Marketplace Listing Generator

                                                                      +
                                                                      +
                                                                      +`; +} + +export async function sendMagicLink(email, token, baseUrl, type = 'login') { + const url = `${baseUrl}/auth/magic?token=${token}`; + + const isInvite = type === 'invite'; + const title = isInvite ? 'Du wurdest eingeladen!' : 'Dein Login-Link'; + const content = isInvite + ? `

                                                                      Du wurdest zum Vendoo eingeladen. Klicke auf den Button, um dein Konto zu aktivieren und loszulegen.

                                                                      Der Link ist 48 Stunden gültig.

                                                                      ` + : `

                                                                      Klicke auf den Button, um dich einzuloggen. Kein Passwort nötig.

                                                                      Der Link ist 15 Minuten gültig und kann nur einmal verwendet werden.

                                                                      `; + const buttonText = isInvite ? 'Einladung annehmen' : 'Jetzt einloggen'; + + const html = brandedHtml(title, content, buttonText, url); + const subject = isInvite ? 'Vendoo — Du wurdest eingeladen' : 'Vendoo — Dein Login-Link'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`MAGIC LINK (${type}) für ${email}:`); + console.log(url); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true, url }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Email senden fehlgeschlagen:', err.message); + console.log(`Fallback — Magic Link für ${email}: ${url}`); + return { sent: false, error: err.message, url }; + } +} + +export async function sendCredentials(email, password, name, baseUrl) { + const title = 'Dein Account wurde erstellt'; + const content = `

                                                                      Hallo${name ? ' ' + name : ''},

                                                                      +

                                                                      Ein Admin hat ein Konto für dich erstellt. Hier sind deine Zugangsdaten:

                                                                      + + + +
                                                                      E-Mail${email}
                                                                      Passwort${password}
                                                                      +

                                                                      Bitte ändere dein Passwort nach dem ersten Login.

                                                                      `; + const buttonText = 'Jetzt einloggen'; + const buttonUrl = baseUrl + '/login.html'; + + const html = brandedHtml(title, content, buttonText, buttonUrl); + const subject2 = 'Vendoo — Dein Account wurde erstellt'; + + if (!transporter) { + console.log(`\n${'='.repeat(60)}`); + console.log(`CREDENTIALS für ${email}:`); + console.log(`Passwort: ${password}`); + console.log(`Login: ${baseUrl}/login.html`); + console.log(`${'='.repeat(60)}\n`); + return { sent: false, consoleFallback: true }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: subject2, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Credentials email failed:', err.message); + console.log(`Fallback — Credentials für ${email}: Passwort=${password}`); + return { sent: false, error: err.message }; + } +} + +export async function sendNotificationEmail(email, subject, title, content) { + const html = brandedHtml(title, content, null, null); + + if (!transporter) { + console.log(`Notification for ${email}: ${subject}`); + return { sent: false }; + } + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: `Vendoo — ${subject}`, + html, + }); + return { sent: true }; + } catch (err) { + console.error('Notification email failed:', err.message); + return { sent: false, error: err.message }; + } +} + +export function isMailerConfigured() { + return transporter !== null; +} + +export function reinitMailer(config) { + if (!config || !config.host || !config.user || !config.pass) { + transporter = null; + return false; + } + const port = parseInt(config.port || '587'); + transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: { user: config.user, pass: config.pass }, + tls: { rejectUnauthorized: false }, + }); + return true; +} + +export function getMailerConfig() { + if (!transporter || !transporter.options) { + return { + host: process.env.SMTP_HOST || '', + port: process.env.SMTP_PORT || '587', + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS ? '••••••••' : '', + from: process.env.SMTP_FROM || process.env.SMTP_USER || '', + configured: transporter !== null, + }; + } + const opts = transporter.options; + return { + host: opts.host || '', + port: String(opts.port || '587'), + user: opts.auth?.user || '', + pass: opts.auth?.pass ? '••••••••' : '', + from: process.env.SMTP_FROM || opts.auth?.user || '', + configured: true, + }; +} + +export async function testMailer(toEmail) { + if (!transporter) throw new Error('SMTP nicht konfiguriert'); + const html = ` + + +

                                                                      Vendoo — SMTP Test

                                                                      +

                                                                      Diese E-Mail bestätigt, dass deine SMTP-Konfiguration korrekt funktioniert.

                                                                      +

                                                                      Gesendet: ${new Date().toLocaleString('de-DE')}

                                                                      +`; + + await transporter.sendMail({ + from: process.env.SMTP_FROM || transporter.options?.auth?.user, + to: toEmail, + subject: 'Vendoo — SMTP Testmail', + html, + }); + return { sent: true }; +} diff --git a/lib/operations-center.mjs b/lib/operations-center.mjs new file mode 100644 index 0000000..ddcccaf --- /dev/null +++ b/lib/operations-center.mjs @@ -0,0 +1,391 @@ +import Database from 'better-sqlite3'; +import { createHash, randomUUID } from 'crypto'; +import { + existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, + unlinkSync, copyFileSync, rmSync, +} from 'fs'; +import { join, relative, dirname, basename, resolve, sep } from 'path'; +import { db } from './db.mjs'; +import { APP_ROOT, DATA_ROOT, DB_PATH, UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, APP_VERSION } from './runtime-paths.mjs'; +import { createZip } from './zip.mjs'; +import { readZip } from './archive.mjs'; + +const AUTO_BACKUP_DIR = join(BACKUP_DIR, 'automatic'); +const MANUAL_BACKUP_DIR = join(BACKUP_DIR, 'manual'); +const STAGING_DIR = join(OPERATIONS_DIR, 'staging'); +const UPDATE_DIR = join(OPERATIONS_DIR, 'updates'); +const PENDING_FILE = join(OPERATIONS_DIR, 'pending-operation.json'); +const LAST_OPERATION_FILE = join(OPERATIONS_DIR, 'last-operation.json'); + +for (const directory of [BACKUP_DIR, AUTO_BACKUP_DIR, MANUAL_BACKUP_DIR, STAGING_DIR, UPDATE_DIR, dirname(PENDING_FILE)]) { + mkdirSync(directory, { recursive: true }); +} + +function nowIso() { return new Date().toISOString(); } +function localDateKey(date = new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } +function safeJson(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } } +function hash(data) { return createHash('sha256').update(data).digest('hex'); } +function cleanLabel(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]+/g, '').slice(0, 60); } +function portable(path) { return String(path || '').replace(/\\/g, '/'); } +function fileSize(path) { try { return statSync(path).size; } catch { return 0; } } +function isInsideAllowedRoot(path) { + const resolvedPath = resolve(path); + return [APP_ROOT, DATA_ROOT].some(root => resolvedPath === resolve(root) || resolvedPath.startsWith(resolve(root) + sep)); +} +function ensureInsideAllowedRoot(path) { if (!isInsideAllowedRoot(path)) throw new Error('Pfad liegt außerhalb der erlaubten Vendoo-Verzeichnisse.'); return path; } + +function walkFiles(base, prefix = '') { + const result = []; + if (!existsSync(base)) return result; + for (const name of readdirSync(base)) { + const absolute = join(base, name); + const relativeName = portable(join(prefix, name)); + const stat = statSync(absolute); + if (stat.isDirectory()) result.push(...walkFiles(absolute, relativeName)); + else if (stat.isFile()) result.push({ absolute, name: relativeName, size: stat.size }); + } + return result; +} + +function getOperationSettings() { + const rows = db.prepare('SELECT key, value FROM operation_settings').all(); + const values = Object.fromEntries(rows.map(row => [row.key, row.value])); + return { + auto_backup_enabled: values.auto_backup_enabled === '1', + auto_backup_hour: Math.max(0, Math.min(23, Number(values.auto_backup_hour ?? 3))), + auto_backup_retention: Math.max(1, Math.min(30, Number(values.auto_backup_retention ?? 7))), + include_uploads_automatic: values.include_uploads_automatic !== '0', + update_manifest_url: values.update_manifest_url || process.env.VENDOO_UPDATE_MANIFEST_URL || '', + last_auto_backup_date: values.last_auto_backup_date || '', + }; +} + +export function updateOperationSettings(patch = {}) { + const allowed = new Set(['auto_backup_enabled', 'auto_backup_hour', 'auto_backup_retention', 'include_uploads_automatic', 'update_manifest_url']); + const stmt = db.prepare(`INSERT INTO operation_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`); + const tx = db.transaction(() => { + for (const [key, raw] of Object.entries(patch || {})) { + if (!allowed.has(key)) continue; + let value = raw; + if (['auto_backup_enabled', 'include_uploads_automatic'].includes(key)) value = raw ? '1' : '0'; + if (key === 'auto_backup_hour') value = String(Math.max(0, Math.min(23, Number(raw || 0)))); + if (key === 'auto_backup_retention') value = String(Math.max(1, Math.min(30, Number(raw || 7)))); + if (key === 'update_manifest_url') value = String(raw || '').trim().slice(0, 1000); + stmt.run(key, String(value)); + } + }); + tx(); + return getOperationSettings(); +} + +async function createDatabaseSnapshot(destination) { + try { db.pragma('wal_checkpoint(PASSIVE)'); } catch {} + await db.backup(destination); + const checkDb = new Database(destination, { readonly: true }); + try { + const integrity = checkDb.pragma('integrity_check', { simple: true }); + if (integrity !== 'ok') throw new Error(`SQLite-Integritätsprüfung: ${integrity}`); + } finally { checkDb.close(); } +} + +function backupFileRows() { + return db.prepare('SELECT * FROM operation_backups ORDER BY created_at DESC, id DESC').all().map(row => { + const { file_path: filePath, options_json: _optionsJson, manifest_json: _manifestJson, ...safeRow } = row; + return { + ...safeRow, + options: safeJson(row.options_json), + manifest: safeJson(row.manifest_json), + exists: existsSync(filePath), + }; + }); +} + +function rotateAutomaticBackups(retention) { + const rows = db.prepare("SELECT * FROM operation_backups WHERE kind = 'automatic' ORDER BY created_at DESC, id DESC").all(); + for (const row of rows.slice(retention)) { + try { if (existsSync(row.file_path)) unlinkSync(row.file_path); } catch {} + db.prepare('DELETE FROM operation_backups WHERE id = ?').run(row.id); + } +} + +export async function createBackup({ kind = 'manual', includeUploads = true, includeConfig = false, label = '', automatic = false } = {}) { + const id = randomUUID(); + const createdAt = nowIso(); + const safeKind = automatic ? 'automatic' : (kind === 'database' ? 'database' : 'manual'); + const directory = automatic ? AUTO_BACKUP_DIR : MANUAL_BACKUP_DIR; + const stamp = createdAt.replace(/[:.]/g, '-'); + const suffix = cleanLabel(label) ? `-${cleanLabel(label).replace(/\s+/g, '-')}` : ''; + const filename = `vendoo-${safeKind}-${stamp}${suffix}.zip`; + const outputPath = join(directory, filename); + const tempDb = join(STAGING_DIR, `backup-${id}.db`); + await createDatabaseSnapshot(tempDb); + + const sourceFiles = [{ absolute: tempDb, name: 'db/vendoo.db', size: fileSize(tempDb), role: 'database' }]; + if (includeUploads) sourceFiles.push(...walkFiles(UPLOADS_DIR, 'uploads').map(file => ({ ...file, role: 'upload' }))); + if (includeConfig && existsSync(join(APP_ROOT, '.env'))) sourceFiles.push({ absolute: join(APP_ROOT, '.env'), name: 'config/.env', size: fileSize(join(APP_ROOT, '.env')), role: 'config-secret' }); + for (const name of ['package.json', '.env.example']) { + const absolute = join(APP_ROOT, name); + if (existsSync(absolute)) sourceFiles.push({ absolute, name: `metadata/${name}`, size: fileSize(absolute), role: 'metadata' }); + } + + const maxBytes = 2 * 1024 * 1024 * 1024; + const totalBytes = sourceFiles.reduce((sum, file) => sum + file.size, 0); + if (totalBytes > maxBytes) { + rmSync(tempDb, { force: true }); + throw new Error('Backup überschreitet 2 GB. Bitte Uploads separat sichern oder alte Dateien bereinigen.'); + } + + const payloadFiles = []; + const manifestFiles = []; + for (const file of sourceFiles) { + const data = readFileSync(ensureInsideAllowedRoot(file.absolute)); + payloadFiles.push({ name: file.name, data }); + manifestFiles.push({ path: file.name, role: file.role, size: data.length, sha256: hash(data) }); + } + const manifest = { + format: 'vendoo-backup-v1', version: APP_VERSION, id, kind: safeKind, created_at: createdAt, + include_uploads: Boolean(includeUploads), include_config: Boolean(includeConfig), + file_count: manifestFiles.length, total_uncompressed_bytes: totalBytes, files: manifestFiles, + }; + payloadFiles.unshift({ name: 'backup-manifest.json', data: Buffer.from(JSON.stringify(manifest, null, 2), 'utf8') }); + const archive = createZip(payloadFiles); + writeFileSync(outputPath, archive); + rmSync(tempDb, { force: true }); + + const verification = verifyBackupBuffer(archive); + if (!verification.ok) { + rmSync(outputPath, { force: true }); + throw new Error(`Backup-Prüfung fehlgeschlagen: ${verification.errors.join('; ')}`); + } + db.prepare(`INSERT INTO operation_backups + (id, kind, label, file_name, file_path, file_size, sha256, status, verified_at, options_json, manifest_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'verified', datetime('now'), ?, ?, datetime('now'))`).run( + id, safeKind, cleanLabel(label), filename, outputPath, fileSize(outputPath), hash(archive), + JSON.stringify({ includeUploads: Boolean(includeUploads), includeConfig: Boolean(includeConfig) }), JSON.stringify(manifest), + ); + if (automatic) rotateAutomaticBackups(getOperationSettings().auto_backup_retention); + return { id, filename, file_size: fileSize(outputPath), sha256: hash(archive), manifest, verification }; +} + +export function verifyBackupBuffer(buffer) { + const errors = []; + const warnings = []; + let manifest = null; + try { + const { files } = readZip(buffer, { maxEntries: 20000, maxUncompressedBytes: 3 * 1024 * 1024 * 1024 }); + const manifestBuffer = files.get('backup-manifest.json'); + if (!manifestBuffer) throw new Error('backup-manifest.json fehlt.'); + manifest = JSON.parse(manifestBuffer.toString('utf8')); + if (manifest.format !== 'vendoo-backup-v1') errors.push('Unbekanntes Backup-Format.'); + for (const entry of manifest.files || []) { + const data = files.get(entry.path); + if (!data) { errors.push(`Datei fehlt: ${entry.path}`); continue; } + if (data.length !== Number(entry.size)) errors.push(`Dateigröße stimmt nicht: ${entry.path}`); + if (hash(data) !== entry.sha256) errors.push(`Prüfsumme stimmt nicht: ${entry.path}`); + } + const dbData = files.get('db/vendoo.db'); + if (!dbData) errors.push('Datenbank fehlt im Backup.'); + else { + const temp = join(STAGING_DIR, `verify-${randomUUID()}.db`); + writeFileSync(temp, dbData); + try { + const verifyDb = new Database(temp, { readonly: true }); + const integrity = verifyDb.pragma('integrity_check', { simple: true }); + verifyDb.close(); + if (integrity !== 'ok') errors.push(`SQLite-Integrität: ${integrity}`); + } catch (error) { errors.push(`Datenbank kann nicht geöffnet werden: ${error.message}`); } + finally { rmSync(temp, { force: true }); } + } + if (manifest.include_config) warnings.push('Dieses Backup enthält Zugangsdaten aus .env. Sicher aufbewahren.'); + } catch (error) { errors.push(error.message); } + return { ok: errors.length === 0, errors, warnings, manifest }; +} + +export function verifyStoredBackup(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row || !existsSync(row.file_path)) throw new Error('Backup wurde nicht gefunden.'); + const buffer = readFileSync(row.file_path); + const result = verifyBackupBuffer(buffer); + db.prepare(`UPDATE operation_backups SET status = ?, verified_at = datetime('now'), sha256 = ?, file_size = ?, manifest_json = ? WHERE id = ?`).run( + result.ok ? 'verified' : 'invalid', hash(buffer), buffer.length, JSON.stringify(result.manifest || {}), id, + ); + return result; +} + +export function listBackups() { return backupFileRows(); } +export function getBackupPath(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row || !existsSync(row.file_path)) throw new Error('Backup wurde nicht gefunden.'); + return row; +} +export function deleteBackup(id) { + const row = db.prepare('SELECT * FROM operation_backups WHERE id = ?').get(id); + if (!row) return { deleted: 0 }; + try { if (existsSync(row.file_path)) unlinkSync(row.file_path); } catch {} + const result = db.prepare('DELETE FROM operation_backups WHERE id = ?').run(id); + return { deleted: Number(result.changes || 0) }; +} + +function writePendingOperation(operation) { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + writeFileSync(PENDING_FILE, JSON.stringify({ ...operation, created_at: nowIso(), app_version: APP_VERSION }, null, 2), 'utf8'); + return { ...operation, pending_file: PENDING_FILE }; +} + +export async function stageRestore(buffer, originalName = 'backup.zip') { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + const verification = verifyBackupBuffer(buffer); + if (!verification.ok) throw new Error(`Backup ist ungültig: ${verification.errors.join('; ')}`); + const safety = await createBackup({ kind: 'manual', includeUploads: true, includeConfig: false, label: 'vor-wiederherstellung' }); + const id = randomUUID(); + const archivePath = join(STAGING_DIR, `restore-${id}.zip`); + writeFileSync(archivePath, buffer); + return writePendingOperation({ id, type: 'restore', archive_path: archivePath, original_name: basename(originalName), safety_backup_id: safety.id, verified: true, manifest: verification.manifest }); +} + +function versionParts(version) { return String(version || '0').replace(/^v/, '').split(/[.-]/).map(part => Number(part) || 0); } +function compareVersions(left, right) { + const a = versionParts(left); const b = versionParts(right); + for (let i = 0; i < Math.max(a.length, b.length); i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0) ? 1 : -1; } + return 0; +} + +function inspectUpdateArchive(buffer) { + const { entries, files } = readZip(buffer, { maxEntries: 30000, maxUncompressedBytes: 4 * 1024 * 1024 * 1024 }); + const packageNames = [...files.keys()].filter(name => name === 'package.json' || name.endsWith('/vendoo/package.json') || /\/package\.json$/.test(name)); + if (!packageNames.length) throw new Error('Kein Vendoo-package.json im Update gefunden.'); + packageNames.sort((a, b) => a.split('/').length - b.split('/').length); + const packagePath = packageNames[0]; + const packageData = JSON.parse(files.get(packagePath).toString('utf8')); + if (packageData.name !== 'vendoo') throw new Error('Das Paket ist kein Vendoo-Update.'); + const rootPrefix = packagePath.slice(0, -'package.json'.length); + const relativeNames = entries.filter(entry => entry.name.startsWith(rootPrefix)).map(entry => entry.name.slice(rootPrefix.length)); + const forbidden = relativeNames.filter(name => + name === '.env' || name.startsWith('db/vendoo.db') || name.startsWith('uploads/') || + name.startsWith('node_modules/') || name.startsWith('backups/') || name.startsWith('operations/') || + name.startsWith('logs/') || name.startsWith('local-ai/comfyui/') || name.startsWith('local-ai/downloads/') + ); + if (forbidden.length) throw new Error(`Update enthält verbotene Laufzeitdaten: ${forbidden.slice(0, 5).join(', ')}`); + for (const required of ['server.mjs', 'public/index.html', 'public/app.js', 'public/style.css', 'setup.ps1']) { + if (!files.has(rootPrefix + required)) throw new Error(`Pflichtdatei fehlt im Update: ${required}`); + } + return { version: packageData.version, package: packageData, root_prefix: rootPrefix, entries: relativeNames.length }; +} + +export async function stageUpdate(buffer, originalName = 'vendoo-update.zip') { + if (existsSync(PENDING_FILE)) throw new Error('Es ist bereits eine ausstehende Systemoperation vorhanden.'); + const inspection = inspectUpdateArchive(buffer); + if (compareVersions(inspection.version, APP_VERSION) <= 0) throw new Error(`Update-Version ${inspection.version} ist nicht neuer als ${APP_VERSION}.`); + const safety = await createBackup({ kind: 'manual', includeUploads: true, includeConfig: false, label: `vor-update-${inspection.version}` }); + const id = randomUUID(); + const archivePath = join(UPDATE_DIR, `vendoo-update-${inspection.version}-${id}.zip`); + writeFileSync(archivePath, buffer); + db.prepare(`INSERT INTO update_operations (id, version_from, version_to, source_name, archive_path, sha256, status, details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'staged', ?, datetime('now'))`).run( + id, APP_VERSION, inspection.version, basename(originalName), archivePath, hash(buffer), JSON.stringify({ ...inspection, safety_backup_id: safety.id }), + ); + return writePendingOperation({ id, type: 'update', archive_path: archivePath, original_name: basename(originalName), version_from: APP_VERSION, version_to: inspection.version, root_prefix: inspection.root_prefix, safety_backup_id: safety.id, verified: true }); +} + +function syncLastOperationState() { + if (!existsSync(LAST_OPERATION_FILE)) return; + const last = safeJson(readFileSync(LAST_OPERATION_FILE, 'utf8'), null); + if (!last?.id || last.type !== 'update') return; + try { + db.prepare(`UPDATE update_operations SET status = ?, applied_at = COALESCE(applied_at, ?), error_message = ? WHERE id = ?`).run( + last.status || 'applied', last.applied_at || null, last.error_message || null, last.id, + ); + } catch {} +} + +export function getOperationsStatus() { + syncLastOperationState(); + const pending = existsSync(PENDING_FILE) ? safeJson(readFileSync(PENDING_FILE, 'utf8'), null) : null; + const last = existsSync(LAST_OPERATION_FILE) ? safeJson(readFileSync(LAST_OPERATION_FILE, 'utf8'), null) : null; + const latestUpdate = db.prepare('SELECT * FROM update_operations ORDER BY created_at DESC LIMIT 1').get() || null; + const migrations = db.prepare('SELECT * FROM system_migrations ORDER BY applied_at DESC, id DESC').all(); + let safeUpdate = null; + if (latestUpdate) { + const { archive_path: _archivePath, details_json: _detailsJson, ...row } = latestUpdate; + safeUpdate = { ...row, details: safeJson(latestUpdate.details_json) }; + } + return { version: APP_VERSION, pending, last, latest_update: safeUpdate, migrations, settings: getOperationSettings() }; +} + +export function cancelPendingOperation() { + if (!existsSync(PENDING_FILE)) return { cancelled: false }; + const pending = safeJson(readFileSync(PENDING_FILE, 'utf8'), {}); + rmSync(PENDING_FILE, { force: true }); + return { cancelled: true, pending }; +} + +export async function checkForUpdates() { + const settings = getOperationSettings(); + const url = String(settings.update_manifest_url || '').trim(); + if (!url) return { configured: false, current_version: APP_VERSION, message: 'Keine Update-Manifest-URL konfiguriert.' }; + if (!/^https?:\/\//i.test(url)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`); + const manifest = await response.json(); + const latest = String(manifest.version || manifest.latest_version || ''); + if (!latest) throw new Error('Update-Manifest enthält keine Version.'); + return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest }; + } finally { clearTimeout(timeout); } +} + +export function recordExtensionHeartbeat({ clientId, browser, version, platform, capabilities, serverUrl, userAgent } = {}) { + const id = String(clientId || '').trim().slice(0, 120) || randomUUID(); + db.prepare(`INSERT INTO extension_clients + (client_id, browser, version, platform, server_url, user_agent, capabilities_json, last_seen, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(client_id) DO UPDATE SET browser=excluded.browser, version=excluded.version, platform=excluded.platform, + server_url=excluded.server_url, user_agent=excluded.user_agent, capabilities_json=excluded.capabilities_json, last_seen=datetime('now')`).run( + id, String(browser || 'unknown').slice(0, 40), String(version || '').slice(0, 30), String(platform || '').slice(0, 40), + String(serverUrl || '').slice(0, 500), String(userAgent || '').slice(0, 1000), JSON.stringify(capabilities || {}), + ); + return { ok: true, client_id: id, server_version: APP_VERSION, time: nowIso() }; +} + +function manifestVersion(path) { + try { return JSON.parse(readFileSync(path, 'utf8')).version || null; } catch { return null; } +} + +export function getExtensionDiagnostics() { + const packages = { + chrome: { path: join(APP_ROOT, 'extensions', 'chrome', 'manifest.json') }, + edge: { path: join(APP_ROOT, 'extensions', 'edge', 'manifest.json') }, + firefox: { path: join(APP_ROOT, 'extensions', 'firefox', 'manifest.json') }, + safari: { path: join(APP_ROOT, 'extensions', 'safari', 'web-extension', 'manifest.json') }, + }; + for (const item of Object.values(packages)) Object.assign(item, { exists: existsSync(item.path), version: manifestVersion(item.path) }); + const clients = db.prepare(`SELECT *, CAST((julianday('now') - julianday(last_seen)) * 86400 AS INTEGER) AS age_seconds + FROM extension_clients ORDER BY last_seen DESC LIMIT 30`).all().map(row => ({ ...row, capabilities: safeJson(row.capabilities_json) })); + return { expected_version: '1.9.0', packages, clients, connected: clients.filter(client => Number(client.age_seconds) <= 180).length }; +} + +let automaticTimer = null; +export function startAutomaticBackupLoop() { + if (automaticTimer) return; + const tick = async () => { + const settings = getOperationSettings(); + if (!settings.auto_backup_enabled) return; + const now = new Date(); + const date = localDateKey(now); + if (now.getHours() !== settings.auto_backup_hour || settings.last_auto_backup_date === date) return; + try { + await createBackup({ automatic: true, includeUploads: settings.include_uploads_automatic, includeConfig: false, label: 'automatisch' }); + db.prepare(`INSERT INTO operation_settings (key, value, updated_at) VALUES ('last_auto_backup_date', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`).run(date); + } catch (error) { console.error('Automatisches Backup fehlgeschlagen:', error.message); } + }; + automaticTimer = setInterval(tick, 15 * 60 * 1000); + automaticTimer.unref?.(); + const initialTimer = setTimeout(tick, 5000); + initialTimer.unref?.(); +} + +export const operationsPaths = { APP_ROOT, DATA_ROOT, DB_PATH, UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, PENDING_FILE, LAST_OPERATION_FILE }; diff --git a/lib/publishing-jobs.mjs b/lib/publishing-jobs.mjs new file mode 100644 index 0000000..e4c14d5 --- /dev/null +++ b/lib/publishing-jobs.mjs @@ -0,0 +1,259 @@ +import { randomUUID, createHash } from 'crypto'; +import { db, getListing, createPublishLog, updatePublishLog } from './db.mjs'; + +const ACTIVE = new Set(['queued', 'running', 'awaiting_user', 'retry_wait']); +const TERMINAL = new Set(['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked']); +const DIRECT = new Set(['ebay-de', 'etsy']); +let timer = null; +let processing = false; +let processors = null; + +function parseJson(value, fallback = {}) { + try { return JSON.parse(value || ''); } catch { return fallback; } +} +function serialize(row) { + if (!row) return null; + return { ...row, payload: parseJson(row.payload_json), result: parseJson(row.result_json) }; +} +function modeFor(platform) { return DIRECT.has(platform) ? 'api' : 'extension'; } +function keyFor(listingId, platform, action, nonce = '') { + return createHash('sha256').update(`${listingId}|${platform}|${action}|${nonce}`).digest('hex'); +} +function addEvent(job, eventType, message, level = 'info', details = {}) { + db.prepare(`INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message, details_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(job?.id || null, job.listing_id, job.platform, level, eventType, message, JSON.stringify(details || {})); +} +function latestPublished(listingId, platform) { + return db.prepare(`SELECT * FROM publish_log WHERE listing_id = ? AND platform = ? AND status = 'published' ORDER BY id DESC LIMIT 1`).get(listingId, platform); +} +function currentActive(listingId, platform, action) { + return db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND action = ? + AND status IN ('queued','running','awaiting_user','retry_wait') ORDER BY created_at DESC LIMIT 1`).get(listingId, platform, action); +} +export function enqueuePublishingJob({ listingId, platform, action = 'publish', force = false, payload = {}, maxRetries = 3 } = {}) { + const listing = getListing(Number(listingId)); + if (!listing) throw new Error('Artikel nicht gefunden'); + const target = platform || listing.platform; + if (!target) throw new Error('Plattform fehlt'); + const existing = currentActive(listing.id, target, action); + if (existing) return { job: serialize(existing), duplicate: true }; + if (action === 'publish' && !force) { + const live = latestPublished(listing.id, target); + if (live?.external_id) { + const error = new Error('Artikel ist auf dieser Plattform bereits als veröffentlicht protokolliert'); + error.code = 'DUPLICATE_PUBLISH'; + error.existing = live; + throw error; + } + } + const id = randomUUID(); + const mode = modeFor(target); + const status = mode === 'extension' && action === 'publish' ? 'awaiting_user' : 'queued'; + const nonce = force ? randomUUID() : `${new Date().toISOString()}`; + const key = keyFor(listing.id, target, action, nonce); + db.prepare(`INSERT INTO publishing_jobs + (id, listing_id, platform, mode, action, status, phase, idempotency_key, max_retries, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, listing.id, target, mode, action, status, status, key, Math.min(Math.max(Number(maxRetries) || 3, 0), 10), JSON.stringify(payload || {})); + const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(job, 'created', mode === 'api' ? 'Publishing-Auftrag eingereiht' : 'Auftrag wartet auf Browser-Extension'); + return { job, duplicate: false }; +} +export function getPublishingJob(id) { + const row = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id WHERE j.id = ?`).get(id); + if (!row) return null; + const job = serialize(row); + job.events = db.prepare('SELECT * FROM publishing_events WHERE job_id = ? ORDER BY id DESC LIMIT 100').all(id).map(event => ({ ...event, details: parseJson(event.details_json) })); + return job; +} +export function paginatePublishingJobs({ page = 1, pageSize = 10, status = '', platform = '', q = '' } = {}) { + const size = [5, 10, 15, 25, 50].includes(Number(pageSize)) ? Number(pageSize) : 10; + const current = Math.max(1, Number(page) || 1); + const where = []; + const params = []; + if (status) { where.push('j.status = ?'); params.push(status); } + if (platform) { where.push('j.platform = ?'); params.push(platform); } + if (q) { where.push('(l.title LIKE ? OR l.sku LIKE ? OR j.external_id LIKE ? OR j.error_message LIKE ?)'); const needle = `%${q}%`; params.push(needle, needle, needle, needle); } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const total = db.prepare(`SELECT COUNT(*) AS count FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause}`).get(...params).count; + const rows = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause} + ORDER BY CASE j.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 WHEN 'awaiting_user' THEN 2 WHEN 'retry_wait' THEN 3 WHEN 'failed' THEN 4 ELSE 5 END, j.created_at DESC + LIMIT ? OFFSET ?`).all(...params, size, (current - 1) * size); + const summaryRows = db.prepare(`SELECT status, COUNT(*) AS count FROM publishing_jobs GROUP BY status`).all(); + return { + items: rows.map(serialize), + pagination: { page: current, page_size: size, total, total_pages: Math.max(1, Math.ceil(total / size)) }, + summary: Object.fromEntries(summaryRows.map(row => [row.status, row.count])), + }; +} +export function listPublishingEvents({ limit = 100, level = '', platform = '' } = {}) { + const where = []; + const params = []; + if (level) { where.push('e.level = ?'); params.push(level); } + if (platform) { where.push('e.platform = ?'); params.push(platform); } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + return db.prepare(`SELECT e.*, l.title, l.sku FROM publishing_events e JOIN listings l ON l.id = e.listing_id ${clause} ORDER BY e.id DESC LIMIT ?`) + .all(...params, Math.min(Math.max(Number(limit) || 100, 1), 500)).map(row => ({ ...row, details: parseJson(row.details_json) })); +} +export function retryPublishingJob(id) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + if (!['failed', 'blocked', 'cancelled'].includes(job.status)) throw new Error('Nur fehlgeschlagene, blockierte oder abgebrochene Aufträge können wiederholt werden'); + const next = modeFor(job.platform) === 'extension' && job.action === 'publish' ? 'awaiting_user' : 'queued'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, error_code = NULL, error_message = NULL, next_attempt_at = NULL, + completed_at = NULL, updated_at = datetime('now') WHERE id = ?`).run(next, next, id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(updated, 'retry', 'Auftrag erneut eingeplant'); + return updated; +} +export function cancelPublishingJob(id) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + if (TERMINAL.has(job.status)) return serialize(job); + db.prepare(`UPDATE publishing_jobs SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`).run(id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + addEvent(updated, 'cancelled', 'Auftrag abgebrochen', 'warning'); + return updated; +} +export function deletePublishingJobs(ids = []) { + const safe = [...new Set(ids.map(String).filter(Boolean))]; + if (!safe.length) return 0; + const placeholders = safe.map(() => '?').join(','); + const rows = db.prepare(`SELECT id, status FROM publishing_jobs WHERE id IN (${placeholders})`).all(...safe); + if (rows.some(row => ACTIVE.has(row.status))) throw new Error('Aktive Aufträge können nicht gelöscht werden'); + return db.prepare(`DELETE FROM publishing_jobs WHERE id IN (${placeholders})`).run(...safe).changes; +} +export function claimExtensionJob({ platform = 'vinted', clientId = 'extension' } = {}) { + const job = db.prepare(`SELECT * FROM publishing_jobs WHERE platform = ? AND mode = 'extension' AND status = 'awaiting_user' + ORDER BY created_at LIMIT 1`).get(platform); + if (!job) return null; + db.prepare(`UPDATE publishing_jobs SET claimed_by = ?, claimed_at = datetime('now'), phase = 'claimed', updated_at = datetime('now') WHERE id = ?`).run(clientId, job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + addEvent(updated, 'claimed', 'Auftrag von Browser-Extension übernommen', 'info', { clientId }); + return { ...updated, listing: getListing(updated.listing_id) }; +} +export function completeExtensionJob(id, { status = 'published', externalId = null, externalUrl = null, error = null } = {}) { + const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id); + if (!job) throw new Error('Auftrag nicht gefunden'); + const nextStatus = status === 'published' ? 'published' : status === 'draft' ? 'awaiting_user' : 'failed'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url), + error_message = ?, completed_at = CASE WHEN ? IN ('published','failed') THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`) + .run(nextStatus, nextStatus, externalId, externalUrl, error, nextStatus, id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + if (nextStatus === 'published') createPublishLog(updated.listing_id, updated.platform, 'published', externalId, externalUrl); + addEvent(updated, nextStatus, nextStatus === 'published' ? 'Extension-Auftrag als veröffentlicht bestätigt' : (error || 'Extension-Auftrag aktualisiert'), nextStatus === 'failed' ? 'error' : 'info'); + return updated; +} +function markRunning(job) { + db.prepare(`UPDATE publishing_jobs SET status = 'running', phase = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.action, job.id); + return serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); +} +function markSuccess(job, result = {}) { + const status = job.action === 'end' ? 'ended' : (job.action === 'sync' ? (result.status || 'published') : (String(result.status || '').toLowerCase() === 'draft' ? 'drafted' : 'published')); + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = 'completed', external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url), + offer_id = COALESCE(?, offer_id), result_json = ?, error_code = NULL, error_message = NULL, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`) + .run(status, result.externalId || result.listingId || null, result.url || null, result.offerId || null, JSON.stringify(result || {}), job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + if (job.action === 'publish') { + createPublishLog(job.listing_id, job.platform, status === 'drafted' ? 'draft' : 'published', updated.external_id, updated.external_url); + const createdLog = db.prepare('SELECT id FROM publish_log WHERE listing_id = ? AND platform = ? ORDER BY id DESC LIMIT 1').get(job.listing_id, job.platform); + if (createdLog) updatePublishLog(createdLog.id, { offer_id: updated.offer_id, sync_status: status, last_synced_at: new Date().toISOString(), updated_at: new Date().toISOString() }); + } else { + const log = latestPublished(job.listing_id, job.platform); + if (log) updatePublishLog(log.id, { + status: job.action === 'end' ? 'ended' : (result.status || log.status), + external_id: updated.external_id, + external_url: updated.external_url, + offer_id: updated.offer_id, + sync_status: result.status || status, + last_synced_at: new Date().toISOString(), + ended_at: job.action === 'end' ? new Date().toISOString() : undefined, + updated_at: new Date().toISOString(), + }); + } + addEvent(updated, 'completed', job.action === 'end' ? 'Angebot beendet' : job.action === 'sync' ? 'Status synchronisiert' : 'Veröffentlichung erfolgreich'); + return updated; +} +function markFailure(job, error) { + const retries = Number(job.retries || 0) + 1; + const canRetry = retries <= Number(job.max_retries || 3); + const delayMinutes = Math.min(30, Math.max(1, 2 ** Math.max(0, retries - 1))); + const status = canRetry ? 'retry_wait' : 'failed'; + db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, retries = ?, error_code = ?, error_message = ?, + next_attempt_at = CASE WHEN ? = 'retry_wait' THEN datetime('now', '+' || ? || ' minutes') ELSE NULL END, + completed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`) + .run(status, status, retries, error?.code || 'PUBLISH_ERROR', error?.message || String(error), status, delayMinutes, status, job.id); + const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id)); + addEvent(updated, status, error?.message || String(error), 'error', { retries, nextRetryMinutes: canRetry ? delayMinutes : null }); + return updated; +} +async function processOne() { + if (!processors) return; + const row = db.prepare(`SELECT * FROM publishing_jobs WHERE mode = 'api' AND ( + status = 'queued' OR (status = 'retry_wait' AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now'))) + ) ORDER BY created_at LIMIT 1`).get(); + if (!row) return; + const job = markRunning(row); + try { + const listing = getListing(job.listing_id); + if (!listing) throw new Error('Artikel nicht gefunden'); + const result = await processors.process(job, listing); + markSuccess(job, result || {}); + } catch (error) { markFailure(job, error); } +} +export function configurePublishingProcessors(value) { processors = value; } +export function startPublishingJobLoop(intervalMs = 1500) { + if (timer) return; + const tick = async () => { + if (processing) return; + processing = true; + try { await processOne(); } finally { processing = false; } + }; + timer = setInterval(tick, intervalMs); + timer.unref?.(); + tick(); +} + +export function migrateLegacyEbayQueue() { + let rows = []; + try { rows = db.prepare('SELECT * FROM ebay_queue ORDER BY id').all(); } catch { return { migrated: 0, skipped: 0 }; } + let migrated = 0; + let skipped = 0; + for (const row of rows) { + const key = `legacy-ebay-queue-${row.id}`; + const exists = db.prepare('SELECT id FROM publishing_jobs WHERE idempotency_key = ?').get(key); + if (exists) { skipped += 1; continue; } + const statusMap = { pending: 'queued', uploading: 'queued', failed: 'failed', published: 'published' }; + const status = statusMap[row.status] || 'queued'; + const id = randomUUID(); + db.prepare(`INSERT OR IGNORE INTO publishing_jobs + (id, listing_id, platform, mode, action, status, phase, idempotency_key, external_id, external_url, + retries, max_retries, error_message, created_at, completed_at, updated_at) + VALUES (?, ?, 'ebay-de', 'api', 'publish', ?, ?, ?, ?, ?, ?, 3, ?, COALESCE(?, datetime('now')), + CASE WHEN ? IN ('published','failed') THEN COALESCE(?, datetime('now')) ELSE NULL END, datetime('now'))`) + .run(id, row.listing_id, status, row.status === 'uploading' ? 'recovered' : status, key, + row.ebay_item_id || null, row.ebay_url || null, Number(row.retries || 0), row.error || null, + row.created_at || null, status, row.updated_at || row.created_at || null); + const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id)); + if (job) { + addEvent(job, 'legacy_migrated', 'Eintrag aus der bisherigen eBay-Queue übernommen', 'info', { legacyQueueId: row.id, legacyStatus: row.status }); + migrated += 1; + } + } + return { migrated, skipped }; +} + +export function recoverPublishingJobs() { + const changes = db.prepare(`UPDATE publishing_jobs SET status = 'queued', phase = 'recovered', updated_at = datetime('now') WHERE status = 'running'`).run().changes; + return changes; +} +export function getPublishCapabilities() { + return { + platforms: { + 'ebay-de': { mode: 'api', publish: true, update: true, end: true, sync: true }, + etsy: { mode: 'api', publish: true, update: false, end: false, sync: true }, + vinted: { mode: 'extension', publish: true, update: false, end: false, sync: false }, + 'ebay-ka': { mode: 'extension', publish: true, update: false, end: false, sync: false }, + }, + }; +} diff --git a/lib/quality-center.mjs b/lib/quality-center.mjs new file mode 100644 index 0000000..85beba8 --- /dev/null +++ b/lib/quality-center.mjs @@ -0,0 +1,216 @@ +import sharp from 'sharp'; +import { existsSync } from 'fs'; +import { resolve, sep } from 'path'; +import { db, getListing, getListings } from './db.mjs'; +import { calculateFees } from './fees.mjs'; + +const PLATFORM_PROFILES = { + 'ebay-de': { name: 'eBay', titleMin: 12, titleMax: 80, descriptionMin: 80, recommendedPhotos: 4 }, + vinted: { name: 'Vinted', titleMin: 8, titleMax: 100, descriptionMin: 45, recommendedPhotos: 3 }, + 'ebay-ka': { name: 'Kleinanzeigen', titleMin: 10, titleMax: 65, descriptionMin: 60, recommendedPhotos: 4 }, + etsy: { name: 'Etsy', titleMin: 20, titleMax: 140, descriptionMin: 120, recommendedPhotos: 5 }, +}; + +function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } +function wordTokens(value) { + return new Set(normalizeText(value).toLowerCase().replace(/[^a-z0-9äöüß ]/gi, ' ').split(/\s+/).filter(token => token.length > 2)); +} +function jaccard(a, b) { + const left = wordTokens(a); const right = wordTokens(b); + if (!left.size || !right.size) return 0; + let intersection = 0; + for (const token of left) if (right.has(token)) intersection += 1; + return intersection / (left.size + right.size - intersection); +} +function gradeFor(score) { + if (score >= 92) return 'A'; + if (score >= 82) return 'B'; + if (score >= 70) return 'C'; + if (score >= 55) return 'D'; + return 'E'; +} +function issue({ code, severity = 'recommendation', category, title, detail, points = 0, field = null, action = null }) { + return { code, severity, category, title, detail, points, field, action }; +} +function safeParseJson(value, fallback) { + try { const parsed = JSON.parse(value); return parsed ?? fallback; } catch { return fallback; } +} + +function findDuplicates(listing, allListings) { + const candidates = []; + const title = normalizeText(listing.title); + for (const other of allListings) { + if (Number(other.id) === Number(listing.id)) continue; + const similarity = jaccard(title, other.title); + const sameSku = listing.sku && other.sku && String(listing.sku).toLowerCase() === String(other.sku).toLowerCase(); + if (sameSku || similarity >= 0.72) { + candidates.push({ id: other.id, title: other.title, sku: other.sku, similarity: Math.round(similarity * 100), same_sku: !!sameSku }); + } + } + return candidates.sort((a, b) => Number(b.same_sku) - Number(a.same_sku) || b.similarity - a.similarity).slice(0, 5); +} + +function inspectCore(listing, allListings) { + const issues = []; + const title = normalizeText(listing.title); + const description = normalizeText(listing.description); + const photos = Array.isArray(listing.photos) ? listing.photos.filter(Boolean) : []; + const tags = Array.isArray(listing.tags) ? listing.tags.filter(Boolean) : []; + const profile = PLATFORM_PROFILES[listing.platform] || { name: listing.platform || 'Allgemein', titleMin: 10, titleMax: 100, descriptionMin: 60, recommendedPhotos: 3 }; + + if (!title) issues.push(issue({ code: 'missing_title', severity: 'error', category: 'Inhalt', title: 'Titel fehlt', detail: 'Ohne Titel kann der Artikel nicht zuverlässig veröffentlicht werden.', points: 20, field: 'title' })); + else { + if (title.length < profile.titleMin) issues.push(issue({ code: 'title_too_short', severity: 'warning', category: 'Inhalt', title: 'Titel ist sehr kurz', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.titleMin} Zeichen empfohlen.`, points: 6, field: 'title', action: 'ai_title' })); + if (title.length > profile.titleMax) issues.push(issue({ code: 'title_too_long', severity: 'error', category: 'Plattform', title: 'Titel ist zu lang', detail: `Das aktive Vendoo-Profil sieht höchstens ${profile.titleMax} Zeichen vor.`, points: 10, field: 'title', action: 'ai_title' })); + if (/\b(l@@k|mega|hammer|wow|!!!)\b/i.test(title) || /!{2,}/.test(title)) issues.push(issue({ code: 'title_spam', severity: 'recommendation', category: 'Inhalt', title: 'Titel wirkt werblich', detail: 'Sachliche Titel sind leichter lesbar und wirken vertrauenswürdiger.', points: 2, field: 'title', action: 'ai_title' })); + } + + if (!description) issues.push(issue({ code: 'missing_description', severity: 'error', category: 'Inhalt', title: 'Beschreibung fehlt', detail: 'Zustand, Lieferumfang und relevante Besonderheiten sollten beschrieben werden.', points: 16, field: 'description', action: 'ai_description' })); + else { + if (description.length < profile.descriptionMin) issues.push(issue({ code: 'description_too_short', severity: 'warning', category: 'Inhalt', title: 'Beschreibung ist knapp', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.descriptionMin} Zeichen empfohlen.`, points: 6, field: 'description', action: 'ai_description' })); + if (!/[.!?]$/.test(description)) issues.push(issue({ code: 'description_unfinished', severity: 'recommendation', category: 'Inhalt', title: 'Beschreibung wirkt unvollständig', detail: 'Ein sauberer Abschluss verbessert die Lesbarkeit.', points: 1, field: 'description' })); + } + + if (!photos.length) issues.push(issue({ code: 'missing_photos', severity: 'error', category: 'Bilder', title: 'Keine Bilder vorhanden', detail: 'Mindestens ein aussagekräftiges Originalbild ist erforderlich.', points: 20, field: 'photos' })); + else if (photos.length < profile.recommendedPhotos) issues.push(issue({ code: 'few_photos', severity: 'warning', category: 'Bilder', title: 'Wenige Bilder', detail: `${profile.recommendedPhotos} oder mehr Perspektiven werden für dieses Profil empfohlen.`, points: clamp((profile.recommendedPhotos - photos.length) * 2, 2, 7), field: 'photos' })); + + if (!(Number(listing.price) > 0)) issues.push(issue({ code: 'missing_price', severity: 'error', category: 'Preis', title: 'Preis fehlt', detail: 'Ein positiver Verkaufspreis ist erforderlich.', points: 12, field: 'price' })); + if (!normalizeText(listing.condition)) issues.push(issue({ code: 'missing_condition', severity: 'error', category: 'Metadaten', title: 'Zustand fehlt', detail: 'Der Zustand ist für Käufer und Plattformformulare wichtig.', points: 8, field: 'condition' })); + if (!normalizeText(listing.category)) issues.push(issue({ code: 'missing_category', severity: 'error', category: 'Metadaten', title: 'Kategorie fehlt', detail: 'Eine passende Kategorie verbessert Auffindbarkeit und Publishing.', points: 8, field: 'category' })); + if (!normalizeText(listing.brand)) issues.push(issue({ code: 'missing_brand', severity: 'recommendation', category: 'Metadaten', title: 'Marke nicht angegeben', detail: 'Marke ergänzen, sofern sie am Produkt sicher erkennbar ist.', points: 3, field: 'brand' })); + if (!normalizeText(listing.color)) issues.push(issue({ code: 'missing_color', severity: 'recommendation', category: 'Metadaten', title: 'Farbe nicht angegeben', detail: 'Eine Farbangabe erleichtert Suche und Filterung.', points: 2, field: 'color' })); + if (!tags.length) issues.push(issue({ code: 'missing_tags', severity: 'recommendation', category: 'Reichweite', title: 'Keine Suchbegriffe', detail: 'Passende Tags oder Suchbegriffe können die Auffindbarkeit verbessern.', points: 3, field: 'tags', action: 'ai_tags' })); + if (!normalizeText(listing.sku)) issues.push(issue({ code: 'missing_sku', severity: 'warning', category: 'Organisation', title: 'SKU fehlt', detail: 'Eine eindeutige Artikelnummer erleichtert Lager und Publishing.', points: 4, field: 'sku' })); + if (!normalizeText(listing.storage_location)) issues.push(issue({ code: 'missing_storage', severity: 'recommendation', category: 'Organisation', title: 'Kein Lagerort', detail: 'Ein Lagerort verhindert Suchaufwand nach dem Verkauf.', points: 2, field: 'storage_location' })); + + const duplicates = findDuplicates(listing, allListings); + if (duplicates.some(item => item.same_sku)) issues.push(issue({ code: 'duplicate_sku', severity: 'error', category: 'Duplikate', title: 'Doppelte SKU möglich', detail: 'Mindestens ein anderer Artikel verwendet dieselbe SKU.', points: 10, field: 'sku' })); + else if (duplicates.length) issues.push(issue({ code: 'possible_duplicate', severity: 'warning', category: 'Duplikate', title: 'Ähnlicher Artikel gefunden', detail: 'Titel und Produktdaten ähneln mindestens einem bestehenden Artikel.', points: 5 })); + + const penalty = issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); + const score = clamp(100 - penalty, 0, 100); + const blockers = issues.filter(entry => entry.severity === 'error'); + return { + listing_id: listing.id, + score, + grade: gradeFor(score), + blockers: blockers.length, + warnings: issues.filter(entry => entry.severity === 'warning').length, + recommendations: issues.filter(entry => entry.severity === 'recommendation').length, + ready: blockers.length === 0 && score >= 70, + platform: { id: listing.platform || '', name: profile.name, profile }, + issues, + duplicates, + facts: { + title_length: title.length, + description_length: description.length, + photo_count: photos.length, + tag_count: tags.length, + }, + listing: { + id: listing.id, title: listing.title, sku: listing.sku, platform: listing.platform, status: listing.status, + price: listing.price, photo: photos[0] || null, updated_at: listing.updated_at, created_at: listing.created_at, + }, + }; +} + +async function inspectImages(report, listing, uploadRoot) { + const details = []; + for (const photo of (listing.photos || []).slice(0, 12)) { + const relative = String(photo || '').replace(/^[/\\]+/, ''); + const absolute = resolve(uploadRoot, relative); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) { + details.push({ path: relative, missing: true }); + report.issues.push(issue({ code: 'photo_missing_file', severity: 'error', category: 'Bilder', title: 'Bilddatei fehlt', detail: `Die Datei ${relative || 'unbekannt'} wurde nicht gefunden.`, points: 8, field: 'photos' })); + continue; + } + try { + const meta = await sharp(absolute).metadata(); + const width = Number(meta.width || 0); const height = Number(meta.height || 0); + const megapixels = Math.round((width * height / 1_000_000) * 10) / 10; + details.push({ path: relative, width, height, megapixels, format: meta.format || '' }); + if (Math.min(width, height) < 700) report.issues.push(issue({ code: 'photo_low_resolution', severity: 'warning', category: 'Bilder', title: 'Niedrige Bildauflösung', detail: `${relative}: ${width} × ${height} px.`, points: 3, field: 'photos' })); + if (width === height && width < 1000) report.issues.push(issue({ code: 'photo_small_square', severity: 'recommendation', category: 'Bilder', title: 'Kleines quadratisches Bild', detail: `${relative} ist quadratisch, aber relativ klein.`, points: 1, field: 'photos' })); + } catch { + details.push({ path: relative, unreadable: true }); + report.issues.push(issue({ code: 'photo_unreadable', severity: 'error', category: 'Bilder', title: 'Bild nicht lesbar', detail: `${relative} konnte nicht analysiert werden.`, points: 6, field: 'photos' })); + } + } + report.images = details; + const penalty = report.issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); + report.score = clamp(100 - penalty, 0, 100); + report.grade = gradeFor(report.score); + report.blockers = report.issues.filter(entry => entry.severity === 'error').length; + report.warnings = report.issues.filter(entry => entry.severity === 'warning').length; + report.recommendations = report.issues.filter(entry => entry.severity === 'recommendation').length; + report.ready = report.blockers === 0 && report.score >= 70; + return report; +} + +function persistReport(report) { + const result = db.prepare(`INSERT INTO quality_reports (listing_id, score, grade, ready, blockers, warnings, recommendations, report_json, analyzed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run( + report.listing_id, report.score, report.grade, report.ready ? 1 : 0, report.blockers, report.warnings, report.recommendations, JSON.stringify(report) + ); + return Number(result.lastInsertRowid); +} + +export function getLatestQualityReport(listingId) { + const row = db.prepare('SELECT * FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 1').get(listingId); + if (!row) return null; + const report = safeParseJson(row.report_json, null); + return report ? { ...report, report_id: row.id, analyzed_at: row.analyzed_at } : null; +} + +export async function analyzeListingQuality(listingId, { uploadRoot, persist = true } = {}) { + const listing = getListing(listingId); + if (!listing) return null; + const allListings = getListings(); + let report = inspectCore(listing, allListings); + if (uploadRoot) report = await inspectImages(report, listing, uploadRoot); + const fee = calculateFees(listing.platform, Number(listing.price) || 0); + report.financials = { price: Number(listing.price) || 0, fees: fee.fees || 0, estimated_net: fee.net || 0, note: fee.note || '', breakdown: fee.breakdown || '' }; + report.analyzed_at = new Date().toISOString(); + if (persist) report.report_id = persistReport(report); + return report; +} + +export function listQualityCenter({ query = '', status = 'all', grade = 'all', page = 1, pageSize = 10 } = {}) { + const allListings = getListings(); + const allReports = allListings.map(listing => { + const latest = getLatestQualityReport(listing.id); + const listingChangedAt = Date.parse(String(listing.updated_at || listing.created_at || '').replace(' ', 'T') + 'Z') || 0; + const analyzedAt = Date.parse(String(latest?.analyzed_at || '').replace(' ', 'T') + 'Z') || 0; + return latest && analyzedAt >= listingChangedAt ? latest : inspectCore(listing, allListings); + }); + let reports = [...allReports]; + const q = normalizeText(query).toLowerCase(); + if (q) reports = reports.filter(item => [item.listing?.title, item.listing?.sku, item.platform?.name].some(value => String(value || '').toLowerCase().includes(q))); + if (status === 'ready') reports = reports.filter(item => item.ready); + if (status === 'blocked') reports = reports.filter(item => item.blockers > 0); + if (status === 'review') reports = reports.filter(item => !item.ready && item.blockers === 0); + if (grade !== 'all') reports = reports.filter(item => item.grade === grade); + reports.sort((a, b) => a.score - b.score || String(b.listing?.updated_at || b.listing?.created_at || '').localeCompare(String(a.listing?.updated_at || a.listing?.created_at || ''))); + const safeSize = [5, 10, 15, 25].includes(Number(pageSize)) ? Number(pageSize) : 10; + const total = reports.length; + const totalPages = Math.max(1, Math.ceil(total / safeSize)); + const safePage = clamp(Number(page) || 1, 1, totalPages); + const items = reports.slice((safePage - 1) * safeSize, safePage * safeSize); + const summary = { + total: allReports.length, + ready: allReports.filter(report => report.ready).length, + blocked: allReports.filter(report => report.blockers > 0).length, + average_score: allReports.length ? Math.round(allReports.reduce((sum, report) => sum + report.score, 0) / allReports.length) : 0, + }; + return { items, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary }; +} + +export function getQualityHistory(listingId, limit = 12) { + return db.prepare('SELECT id, score, grade, ready, blockers, warnings, recommendations, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT ?').all(listingId, Math.min(Math.max(Number(limit) || 12, 1), 50)); +} + +export function compareQualityReports(listingId) { + const rows = db.prepare('SELECT id, score, grade, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 2').all(listingId); + return { latest: rows[0] || null, previous: rows[1] || null, delta: rows.length > 1 ? rows[0].score - rows[1].score : 0 }; +} diff --git a/lib/runtime-paths.mjs b/lib/runtime-paths.mjs new file mode 100644 index 0000000..3ca969e --- /dev/null +++ b/lib/runtime-paths.mjs @@ -0,0 +1,60 @@ +import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { dirname, isAbsolute, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID } from 'crypto'; + +export const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function envPath(name, fallback) { + const value = String(process.env[name] || '').trim(); + if (!value) return resolve(fallback); + return resolve(isAbsolute(value) ? value : join(APP_ROOT, value)); +} + +export const DATA_ROOT = envPath('VENDOO_DATA_DIR', APP_ROOT); +export const DB_PATH = envPath('VENDOO_DB_PATH', join(DATA_ROOT, 'db', 'vendoo.db')); +export const UPLOADS_DIR = envPath('VENDOO_UPLOADS_DIR', join(DATA_ROOT, 'uploads')); +export const BACKUP_DIR = envPath('VENDOO_BACKUP_DIR', join(DATA_ROOT, 'backups')); +export const OPERATIONS_DIR = envPath('VENDOO_OPERATIONS_DIR', join(DATA_ROOT, 'operations')); +export const LOG_DIR = envPath('VENDOO_LOG_DIR', join(DATA_ROOT, 'logs')); +export const CONFIG_PATH = envPath('VENDOO_CONFIG_PATH', join(DATA_ROOT, 'config', 'vendoo.env')); + +export const APP_VERSION = JSON.parse(readFileSync(join(APP_ROOT, 'package.json'), 'utf8')).version; + +export function ensureRuntimeDirectories() { + const directories = [dirname(DB_PATH), UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, LOG_DIR, dirname(CONFIG_PATH)]; + for (const directory of directories) mkdirSync(directory, { recursive: true }); + return directories; +} + +export function verifyRuntimeWritable() { + ensureRuntimeDirectories(); + const checks = []; + for (const directory of [dirname(DB_PATH), UPLOADS_DIR, BACKUP_DIR, OPERATIONS_DIR, LOG_DIR, dirname(CONFIG_PATH)]) { + try { + accessSync(directory, constants.R_OK | constants.W_OK); + const probe = join(directory, `.vendoo-write-${process.pid}-${randomUUID()}.tmp`); + writeFileSync(probe, 'ok', { flag: 'wx' }); + unlinkSync(probe); + checks.push({ path: directory, writable: true }); + } catch (error) { + checks.push({ path: directory, writable: false, error: error.message }); + } + } + return checks; +} + +export function runtimePathSummary() { + return { + app_root: APP_ROOT, + data_root: DATA_ROOT, + database: DB_PATH, + uploads: UPLOADS_DIR, + backups: BACKUP_DIR, + operations: OPERATIONS_DIR, + logs: LOG_DIR, + runtime_config: CONFIG_PATH, + }; +} + +ensureRuntimeDirectories(); diff --git a/lib/security.mjs b/lib/security.mjs new file mode 100644 index 0000000..485eb5c --- /dev/null +++ b/lib/security.mjs @@ -0,0 +1,329 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; +import { db } from './db.mjs'; + +export const PERMISSION_CATALOG = Object.freeze([ + { key: 'dashboard.view', label: 'Dashboard ansehen', group: 'Allgemein' }, + { key: 'listings.view', label: 'Artikel ansehen', group: 'Artikel' }, + { key: 'listings.edit', label: 'Artikel erstellen und bearbeiten', group: 'Artikel' }, + { key: 'listings.delete', label: 'Artikel in den Papierkorb verschieben', group: 'Artikel' }, + { key: 'listings.permanent_delete', label: 'Artikel endgültig löschen', group: 'Artikel' }, + { key: 'media.view', label: 'Bilder ansehen und herunterladen', group: 'Bilder' }, + { key: 'media.edit', label: 'Bilder hochladen und bearbeiten', group: 'Bilder' }, + { key: 'media.delete', label: 'Bilder löschen', group: 'Bilder' }, + { key: 'generation.execute', label: 'AI- und FLUX-Generierung starten', group: 'AI & FLUX' }, + { key: 'batch.execute', label: 'Bildproduktion ausführen', group: 'AI & FLUX' }, + { key: 'quality.execute', label: 'Qualitätsanalysen und AI-Verbesserungen', group: 'Qualität' }, + { key: 'publishing.view', label: 'Publishing ansehen', group: 'Publishing' }, + { key: 'publishing.execute', label: 'Veröffentlichungen starten und wiederholen', group: 'Publishing' }, + { key: 'publishing.manage', label: 'Veröffentlichungen beenden und endgültig löschen', group: 'Publishing' }, + { key: 'inventory.edit', label: 'Lagerorte und Bestände ändern', group: 'Organisation' }, + { key: 'templates.edit', label: 'Vorlagen verwalten', group: 'Organisation' }, + { key: 'settings.manage', label: 'Globale Einstellungen ändern', group: 'Administration' }, + { key: 'users.manage', label: 'Benutzer und Rollen verwalten', group: 'Administration' }, + { key: 'sessions.manage', label: 'Fremde Sitzungen beenden', group: 'Administration' }, + { key: 'audit.view', label: 'Audit-Protokoll ansehen und exportieren', group: 'Administration' }, + { key: 'operations.manage', label: 'Backups, Restore und Updates verwalten', group: 'Administration' }, + { key: 'security.manage', label: 'Server-Sicherheit konfigurieren', group: 'Administration' }, +]); + +const ALL_PERMISSIONS = PERMISSION_CATALOG.map(item => item.key); + +export const ROLE_DEFINITIONS = Object.freeze({ + admin: { + key: 'admin', + label: 'Administrator', + description: 'Vollzugriff auf Vendoo, Benutzer, Backups, Updates und Server-Sicherheit.', + permissions: ALL_PERMISSIONS, + }, + manager: { + key: 'manager', + label: 'Manager', + description: 'Operative Leitung mit vollständiger Artikel-, Medien- und Publishing-Verwaltung, ohne System- und Benutzeradministration.', + permissions: ALL_PERMISSIONS.filter(key => ![ + 'users.manage', 'sessions.manage', 'operations.manage', 'security.manage', 'settings.manage', + ].includes(key)), + }, + editor: { + key: 'editor', + label: 'Editor', + description: 'Erstellt und bearbeitet Artikel, Bilder und AI-Inhalte. Keine endgültigen Löschungen oder Systemverwaltung.', + permissions: [ + 'dashboard.view', 'listings.view', 'listings.edit', 'listings.delete', + 'media.view', 'media.edit', 'generation.execute', 'batch.execute', 'quality.execute', + 'publishing.view', 'publishing.execute', 'inventory.edit', 'templates.edit', + ], + }, + publisher: { + key: 'publisher', + label: 'Publisher', + description: 'Prüft Artikel und führt Veröffentlichungen aus, ohne Artikelstammdaten oder Systemeinstellungen zu ändern.', + permissions: [ + 'dashboard.view', 'listings.view', 'media.view', 'quality.execute', + 'publishing.view', 'publishing.execute', + ], + }, + viewer: { + key: 'viewer', + label: 'Leser', + description: 'Reiner Lesezugriff auf Artikel, Bilder, Qualität und Publishing-Status.', + permissions: ['dashboard.view', 'listings.view', 'media.view', 'publishing.view'], + }, +}); + +const MUTATION_DEFAULT_PERMISSION = 'listings.edit'; + +export function normalizeRole(role) { + return ROLE_DEFINITIONS[role] ? role : 'viewer'; +} + +export function getRoleDefinitions() { + return Object.values(ROLE_DEFINITIONS).map(role => ({ ...role, permissions: [...role.permissions] })); +} + +export function getPermissionCatalog() { + return PERMISSION_CATALOG.map(item => ({ ...item })); +} + +export function permissionsForRole(role) { + return new Set(ROLE_DEFINITIONS[normalizeRole(role)].permissions); +} + +export function hasPermission(role, permission) { + if (!permission) return true; + return permissionsForRole(role).has(permission); +} + +export function requiredPermissionForApi(method, path) { + const verb = String(method || 'GET').toUpperCase(); + const route = String(path || '').split('?')[0]; + + if (route === '/api/me' || route.startsWith('/api/my/')) return null; + if (route === '/api/extensions/heartbeat' || route === '/api/extensions/prepare') return null; + + if (route === '/api/backup' || route === '/api/restore') return 'operations.manage'; + if (route.startsWith('/api/admin/users')) return 'users.manage'; + if (route.startsWith('/api/admin/sessions')) return verb === 'GET' ? 'sessions.manage' : 'sessions.manage'; + if (route.startsWith('/api/admin/audit')) return 'audit.view'; + if (route.startsWith('/api/admin/') || route.startsWith('/api/operations/')) return 'operations.manage'; + if (route.startsWith('/api/settings')) return verb === 'GET' ? null : 'settings.manage'; + if (route.startsWith('/api/themes')) return null; // sichere Detail-Policies übernimmt der Platform Kernel + if (route.startsWith('/api/extensions/diagnostics')) return 'security.manage'; + if (route.startsWith('/api/security/')) return verb === 'GET' ? 'security.manage' : 'security.manage'; + if (route.startsWith('/api/locks/admin/')) return 'sessions.manage'; + + if (route.startsWith('/api/trash')) { + if (verb === 'GET') return 'listings.view'; + if (verb === 'DELETE') return 'listings.permanent_delete'; + return 'listings.edit'; + } + if (route.startsWith('/api/listings')) { + if (verb === 'GET') return 'listings.view'; + if (verb === 'DELETE') return 'listings.delete'; + return 'listings.edit'; + } + if (route.startsWith('/api/templates')) return verb === 'GET' ? null : 'templates.edit'; + if (route.startsWith('/api/storage') || route.startsWith('/api/inventory')) return verb === 'GET' ? null : 'inventory.edit'; + + if (route.startsWith('/api/media') || route.startsWith('/api/gallery')) { + if (verb === 'GET') return 'media.view'; + if (verb === 'DELETE') return 'media.delete'; + return 'media.edit'; + } + if (route.startsWith('/api/upload') || route.startsWith('/api/images') || route.startsWith('/api/image-editor') || route.startsWith('/api/mobile-upload-sessions')) { + if (verb === 'GET') return 'media.view'; + if (verb === 'DELETE') return 'media.delete'; + return 'media.edit'; + } + if (route === '/api/html-render' || route === '/api/html-wrap') return 'listings.view'; + if (route === '/api/photos/archive') return 'publishing.execute'; + if (route.startsWith('/api/local-image/install') || route.startsWith('/api/local-image/update') || route.startsWith('/api/local-image/uninstall') || route.startsWith('/api/local-image/regenerate-token')) return 'settings.manage'; + if (route.startsWith('/api/ebay')) { + if (verb === 'GET') return 'publishing.view'; + if (verb === 'DELETE') return 'publishing.manage'; + return 'publishing.execute'; + } + if (route.startsWith('/api/flux') || route.startsWith('/api/local-image') || route.startsWith('/api/ai-model') || route === '/api/generate' || route.startsWith('/api/prompt-lab')) { + return verb === 'GET' ? 'media.view' : 'generation.execute'; + } + if (route.startsWith('/api/image-batch')) return verb === 'GET' ? 'media.view' : 'batch.execute'; + if (route.startsWith('/api/quality')) return verb === 'GET' ? 'listings.view' : 'quality.execute'; + if (route.startsWith('/api/publishing') || route.startsWith('/api/extension/publishing') || route.startsWith('/api/publish') || route.startsWith('/api/schedule')) { + if (verb === 'GET') return 'publishing.view'; + if (verb === 'DELETE' || route.includes('/end') || route.includes('/delete')) return 'publishing.manage'; + return 'publishing.execute'; + } + if (route.startsWith('/api/extension/queue') || route.startsWith('/api/extension/listing')) return 'publishing.view'; + + if (['GET', 'HEAD', 'OPTIONS'].includes(verb)) return null; + return MUTATION_DEFAULT_PERMISSION; +} + +// --- Resource locks ------------------------------------------------------- + +db.exec(` + CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + acquired_at TEXT NOT NULL DEFAULT (datetime('now')), + renewed_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + client_label TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(resource_type, resource_id) + ); + CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at); + CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at); +`); + +try { + db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) + VALUES ('multiuser-security-1.31.0', '1.31.0', 'Rollen, sichere Sitzungen, Bearbeitungssperren und Server-Härtung')`).run(); +} catch {} + +function hashLockToken(token) { + return createHash('sha256').update(String(token || '')).digest('hex'); +} + +function safeEqualHex(a, b) { + try { + const aa = Buffer.from(String(a || ''), 'hex'); + const bb = Buffer.from(String(b || ''), 'hex'); + return aa.length > 0 && aa.length === bb.length && timingSafeEqual(aa, bb); + } catch { return false; } +} + +export function cleanExpiredResourceLocks() { + return db.prepare("DELETE FROM resource_locks WHERE expires_at <= datetime('now')").run().changes; +} + +function hydrateLock(row) { + if (!row) return null; + return { + id: row.id, + resource_type: row.resource_type, + resource_id: row.resource_id, + user_id: row.user_id, + user_name: row.user_name || '', + user_email: row.user_email || '', + acquired_at: row.acquired_at, + renewed_at: row.renewed_at, + expires_at: row.expires_at, + client_label: row.client_label || '', + }; +} + +export function getResourceLock(resourceType, resourceId) { + cleanExpiredResourceLocks(); + const row = db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email + FROM resource_locks l JOIN users u ON u.id = l.user_id + WHERE l.resource_type = ? AND l.resource_id = ? AND l.expires_at > datetime('now')`).get(String(resourceType), String(resourceId)); + return hydrateLock(row); +} + +export function listResourceLocks() { + cleanExpiredResourceLocks(); + return db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email + FROM resource_locks l JOIN users u ON u.id = l.user_id + WHERE l.expires_at > datetime('now') ORDER BY l.renewed_at DESC`).all().map(hydrateLock); +} + +export function acquireResourceLock({ resourceType, resourceId, userId, clientLabel = '', ttlSeconds = 120 }) { + cleanExpiredResourceLocks(); + const type = String(resourceType || '').trim().slice(0, 40); + const id = String(resourceId || '').trim().slice(0, 120); + if (!type || !id) throw new Error('Ressourcentyp und ID sind erforderlich'); + const existing = getResourceLock(type, id); + if (existing && Number(existing.user_id) !== Number(userId)) { + const error = new Error(`${existing.user_name || existing.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Datensatz bereits.`); + error.code = 'LOCKED'; + error.lock = existing; + throw error; + } + + const token = randomBytes(32).toString('hex'); + const tokenHash = hashLockToken(token); + const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); + const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); + const transaction = db.transaction(() => { + db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id); + db.prepare(`INSERT INTO resource_locks + (resource_type, resource_id, user_id, token_hash, expires_at, client_label) + VALUES (?, ?, ?, ?, ?, ?)`).run(type, id, userId, tokenHash, expiresAt, String(clientLabel || '').slice(0, 160)); + }); + transaction(); + return { token, lock: getResourceLock(type, id) }; +} + +export function validateResourceLock({ resourceType, resourceId, userId, token }) { + const type = String(resourceType || ''); + const id = String(resourceId || ''); + const row = db.prepare(`SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND expires_at > datetime('now')`).get(type, id); + if (!row) return { valid: true, lock: null }; + const providedHash = hashLockToken(token); + const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, providedHash); + return { valid, lock: getResourceLock(type, id) }; +} + +export function renewResourceLock({ resourceType, resourceId, userId, token, ttlSeconds = 120 }) { + const validation = validateResourceLock({ resourceType, resourceId, userId, token }); + if (!validation.valid || !validation.lock) { + const error = new Error('Bearbeitungssperre ist abgelaufen oder gehört zu einer anderen Sitzung.'); + error.code = 'LOCK_INVALID'; + error.lock = validation.lock; + throw error; + } + const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); + const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); + db.prepare(`UPDATE resource_locks SET renewed_at = datetime('now'), expires_at = ? + WHERE resource_type = ? AND resource_id = ?`).run(expiresAt, String(resourceType), String(resourceId)); + return getResourceLock(resourceType, resourceId); +} + +export function releaseResourceLock({ resourceType, resourceId, userId, token, force = false }) { + const type = String(resourceType || ''); + const id = String(resourceId || ''); + if (force) return db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id).changes; + const row = db.prepare('SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ?').get(type, id); + if (!row) return 0; + const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, hashLockToken(token)); + if (!valid) { + const error = new Error('Bearbeitungssperre kann nicht durch diese Sitzung aufgehoben werden.'); + error.code = 'LOCK_INVALID'; + throw error; + } + return db.prepare('DELETE FROM resource_locks WHERE id = ?').run(row.id).changes; +} + +// --- Rate limiter --------------------------------------------------------- +const rateBuckets = new Map(); +const rateStats = { allowed: 0, blocked: 0, buckets: 0, last_blocked_at: null }; + +export function consumeRateLimit(key, { limit = 120, windowMs = 60_000 } = {}) { + const now = Date.now(); + const normalizedKey = String(key || 'anonymous'); + let bucket = rateBuckets.get(normalizedKey); + if (!bucket || now >= bucket.resetAt) bucket = { count: 0, resetAt: now + windowMs }; + bucket.count += 1; + rateBuckets.set(normalizedKey, bucket); + rateStats.buckets = rateBuckets.size; + const remaining = Math.max(0, limit - bucket.count); + const allowed = bucket.count <= limit; + if (allowed) rateStats.allowed += 1; + else { + rateStats.blocked += 1; + rateStats.last_blocked_at = new Date().toISOString(); + } + if (rateBuckets.size > 5000) { + for (const [entryKey, entry] of rateBuckets) if (entry.resetAt <= now) rateBuckets.delete(entryKey); + } + return { allowed, remaining, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), resetAt: bucket.resetAt }; +} + +export function getRateLimitStats() { + return { ...rateStats, buckets: rateBuckets.size }; +} + +setInterval(cleanExpiredResourceLocks, 60_000).unref?.(); diff --git a/lib/zip.mjs b/lib/zip.mjs new file mode 100644 index 0000000..e4e84b6 --- /dev/null +++ b/lib/zip.mjs @@ -0,0 +1,89 @@ +import { deflateRawSync } from 'zlib'; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = date.getDate(); + const month = date.getMonth() + 1; + const dosDate = ((year - 1980) << 9) | (month << 5) | day; + return { time, date: dosDate }; +} + +export function createZip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + const stamp = dosDateTime(); + + for (const file of files) { + const name = Buffer.from(String(file.name || 'file.bin').replace(/\\/g, '/'), 'utf8'); + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data || ''); + const compressed = deflateRawSync(data, { level: 6 }); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(stamp.time, 10); + local.writeUInt16LE(stamp.date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(stamp.time, 12); + central.writeUInt16LE(stamp.date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + + offset += local.length + name.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDirectory, end]); +} diff --git a/local-ai/install-state.json b/local-ai/install-state.json new file mode 100644 index 0000000..eb3e5a3 --- /dev/null +++ b/local-ai/install-state.json @@ -0,0 +1,24 @@ +{ + "status": "completed", + "phase": "done", + "progress": 100, + "message": "Lokales FLUX / ComfyUI ist installiert. Der Server wurde gestartet, ist aber noch nicht erreichbar; prüfe das Runtime-Log.", + "started_at": "2026-07-08T11:10:12.0092682+02:00", + "updated_at": "2026-07-08T11:11:30.9530534+02:00", + "heartbeat_at": "2026-07-08T11:11:30.9570892+02:00", + "process_id": 19128, + "elapsed_seconds": 79, + "install_root": "W:\\ChatGPT\\Vendoo\\local-ai\\comfyui", + "comfy_url": "http://127.0.0.1:8188", + "model_path": "W:\\ChatGPT\\Vendoo\\local-ai\\comfyui\\ComfyUI_windows_portable\\ComfyUI\\models\\checkpoints\\flux1-schnell-fp8.safetensors", + "workflow_path": "W:\\ChatGPT\\Vendoo\\local-ai\\workflows\\vendoo_flux_schnell_api.json", + "log_file": "W:\\ChatGPT\\Vendoo\\logs\\local-flux-install.log", + "requested_install_model": false, + "requested_enable_autostart": false, + "requested_start_now": true, + "gpu": "nvidia", + "model_installed": true, + "reachable": false, + "autostart": false, + "comfy_installed": true +} \ No newline at end of file diff --git a/local-ai/start-local-flux.ps1 b/local-ai/start-local-flux.ps1 new file mode 100644 index 0000000..4e44413 --- /dev/null +++ b/local-ai/start-local-flux.ps1 @@ -0,0 +1,210 @@ +param( + [int]$StartupTimeoutSeconds = 180, + [ValidateSet('auto','fast','balanced','lowvram')] + [string]$Profile = 'auto' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if ((Split-Path -Leaf $ScriptDir) -eq 'local-ai') { + $VendooDir = Split-Path -Parent $ScriptDir +} else { + $VendooDir = Split-Path -Parent $ScriptDir +} +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$PortableRoot = Join-Path $LocalAiDir 'comfyui\ComfyUI_windows_portable' +$Python = Join-Path $PortableRoot 'python_embeded\python.exe' +$Main = Join-Path $PortableRoot 'ComfyUI\main.py' +$LogDir = Join-Path $VendooDir 'logs' +$StdoutLog = Join-Path $LogDir 'local-flux-runtime.log' +$StderrLog = Join-Path $LogDir 'local-flux-runtime-error.log' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' +$ComfyUrl = 'http://127.0.0.1:8188' +$ComfyPort = 8188 + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$env:NO_COLOR = '1' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Remove-Ansi([string]$Text) { + if ([string]::IsNullOrEmpty($Text)) { return '' } + $pattern = ([string][char]27) + '\\[[0-?]*[ -/]*[@-~]' + return [regex]::Replace($Text, $pattern, '') +} + +function Read-Tail([string]$Path, [int]$Count = 16) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + try { + return @(Get-Content -LiteralPath $Path -Tail $Count -ErrorAction SilentlyContinue | ForEach-Object { Remove-Ansi ([string]$_) }) + } catch { return @() } +} + +function Save-RuntimeState([string]$Status, [string]$Message, [int]$ProcessId = 0, [int]$Elapsed = 0, [string]$ErrorMessage = '') { + $state = [ordered]@{ + status = $Status + message = $Message + pid = if ($ProcessId -gt 0) { $ProcessId } else { $null } + port = $ComfyPort + url = $ComfyUrl + elapsed_seconds = $Elapsed + updated_at = (Get-Date).ToString('o') + stdout_log = $StdoutLog + stderr_log = $StderrLog + error = $ErrorMessage + stdout_tail = @(Read-Tail $StdoutLog 12) + stderr_tail = @(Read-Tail $StderrLog 16) + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 5) + if ($ProcessId -gt 0) { Write-Utf8NoBom -Path $PidFile -Content ([string]$ProcessId) } +} + + +function Test-ComfyFlag([string]$Flag) { + try { + $help = & $Python -s $Main --help 2>&1 | Out-String + return $help -match [regex]::Escape($Flag) + } catch { return $false } +} + +function Get-NvidiaVramGb { + try { + $line = & nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1 + if ($line -match '^\s*(\d+)') { return [math]::Round(([double]$Matches[1] / 1024), 1) } + } catch {} + return 0 +} + +function Get-PerformanceArguments([string]$RequestedProfile) { + $effective = $RequestedProfile + $vramGb = Get-NvidiaVramGb + if ($effective -eq 'auto') { + if ($vramGb -ge 12) { $effective = 'fast' } + elseif ($vramGb -gt 0 -and $vramGb -lt 8) { $effective = 'lowvram' } + else { $effective = 'balanced' } + } + $flags = New-Object System.Collections.Generic.List[string] + if ($effective -eq 'fast') { + $flags.Add('--highvram') + $flags.Add('--cache-classic') + if (Test-ComfyFlag '--fast') { + $flags.Add('--fast'); $flags.Add('fp16_accumulation'); $flags.Add('cublas_ops') + } + } elseif ($effective -eq 'lowvram') { + $flags.Add('--lowvram') + $flags.Add('--cache-none') + } else { + $flags.Add('--cache-classic') + } + return [pscustomobject]@{ profile = $effective; requested = $RequestedProfile; vram_gb = $vramGb; flags = @($flags) } +} + +function Test-ComfyReady { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 4 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +function Get-ComfyProcess { + try { + $items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and + $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and + $_.CommandLine -match "--port\s+$ComfyPort" + } | Select-Object -First 1) + if ($items.Count -gt 0) { return $items[0] } + return $null + } catch { return $null } +} + +try { + if (-not (Test-Path -LiteralPath $Python)) { throw "Embedded Python wurde nicht gefunden: $Python" } + if (-not (Test-Path -LiteralPath $Main)) { throw "ComfyUI main.py wurde nicht gefunden: $Main" } + + if (Test-ComfyReady) { + $existingReady = Get-ComfyProcess + $readyPid = if ($existingReady) { [int]$existingReady.ProcessId } else { 0 } + Save-RuntimeState 'ready' 'FLUX / ComfyUI ist bereits aktiv und erreichbar.' $readyPid 0 + exit 0 + } + + $existing = Get-ComfyProcess + $comfyPid = 0 + $process = $null + if ($existing) { + $comfyPid = [int]$existing.ProcessId + Save-RuntimeState 'starting' 'Ein vorhandener ComfyUI-Prozess initialisiert noch.' $comfyPid 0 + } else { + Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue + $performance = Get-PerformanceArguments $Profile + $arguments = @( + '-s', $Main, + '--listen', '127.0.0.1', + '--port', [string]$ComfyPort, + '--disable-auto-launch', + '--preview-method', 'none', + '--windows-standalone-build', + '--disable-all-custom-nodes', + '--log-stdout', + '--verbose', 'INFO' + ) + foreach ($flag in $performance.flags) { $arguments += $flag } + $process = Start-Process -FilePath $Python -ArgumentList $arguments -WorkingDirectory $PortableRoot -WindowStyle Hidden -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -PassThru + if (-not $process) { throw 'Der ComfyUI-Prozess konnte nicht gestartet werden.' } + $comfyPid = [int]$process.Id + Save-RuntimeState 'starting' ("FLUX / ComfyUI wird initialisiert · Profil: {0} · VRAM: {1} GB." -f $performance.profile, $performance.vram_gb) $comfyPid 0 + } + + $startedAt = Get-Date + while (((Get-Date) - $startedAt).TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Seconds 2 + $elapsed = [int][math]::Round(((Get-Date) - $startedAt).TotalSeconds) + + if (Test-ComfyReady) { + Save-RuntimeState 'ready' "FLUX / ComfyUI ist nach ${elapsed}s erreichbar." $comfyPid $elapsed + exit 0 + } + + $alive = $false + try { $alive = $null -ne (Get-Process -Id $comfyPid -ErrorAction SilentlyContinue) } catch {} + if (-not $alive) { + $stderrTail = (Read-Tail $StderrLog 24) -join "`n" + $stdoutTail = (Read-Tail $StdoutLog 24) -join "`n" + $detail = if ($stderrTail) { $stderrTail } elseif ($stdoutTail) { $stdoutTail } else { 'Der Prozess wurde ohne verwertbare Ausgabe beendet.' } + Save-RuntimeState 'failed' 'ComfyUI wurde während des Starts beendet.' $comfyPid $elapsed $detail + throw "ComfyUI wurde während des Starts beendet. $detail" + } + + $tail = @((Read-Tail $StdoutLog 3) + (Read-Tail $StderrLog 3)) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $lastLine = if ($tail.Count -gt 0) { [string]$tail[-1] } else { 'ComfyUI initialisiert Python, Datenbank und Modelle.' } + Save-RuntimeState 'starting' "Start läuft seit ${elapsed}s: $lastLine" $comfyPid $elapsed + } + + $timeoutDetail = @((Read-Tail $StderrLog 24) + (Read-Tail $StdoutLog 24)) -join "`n" + Stop-Process -Id $comfyPid -Force -ErrorAction SilentlyContinue + Save-RuntimeState 'failed' "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet." $comfyPid $StartupTimeoutSeconds $timeoutDetail + throw "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet. Prüfe $StderrLog und $StdoutLog." +} catch { + $message = Remove-Ansi ([string]$_.Exception.Message) + $currentPid = 0 + try { if (Test-Path -LiteralPath $PidFile) { $currentPid = [int](Get-Content -LiteralPath $PidFile -Raw).Trim() } } catch {} + $elapsed = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $oldState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $elapsed = [int]$oldState.elapsed_seconds + } + } catch {} + Save-RuntimeState 'failed' 'FLUX / ComfyUI konnte nicht gestartet werden.' $currentPid $elapsed $message + Write-Error $message + exit 1 +} diff --git a/local-ai/stop-local-flux.ps1 b/local-ai/stop-local-flux.ps1 new file mode 100644 index 0000000..73ffc6a --- /dev/null +++ b/local-ai/stop-local-flux.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'SilentlyContinue' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LogDir = Join-Path $VendooDir 'logs' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +$items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and $_.CommandLine -match '--port\s+8188' +}) +foreach ($item in $items) { Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue } +Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$state = [ordered]@{ + status = 'stopped' + message = 'FLUX / ComfyUI wurde beendet.' + pid = $null + port = 8188 + url = 'http://127.0.0.1:8188' + elapsed_seconds = 0 + updated_at = (Get-Date).ToString('o') + error = '' +} +Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) +exit 0 diff --git a/local-ai/workflows/vendoo_flux_schnell_api.json b/local-ai/workflows/vendoo_flux_schnell_api.json new file mode 100644 index 0000000..38794bc --- /dev/null +++ b/local-ai/workflows/vendoo_flux_schnell_api.json @@ -0,0 +1,86 @@ +{ + "3": { + "inputs": { + "ckpt_name": "flux1-schnell-fp8.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "text": "__PROMPT__", + "clip": [ + "3", + 1 + ] + }, + "class_type": "CLIPTextEncode" + }, + "5": { + "inputs": { + "text": "__NEGATIVE_PROMPT__", + "clip": [ + "3", + 1 + ] + }, + "class_type": "CLIPTextEncode" + }, + "6": { + "inputs": { + "width": 1024, + "height": 1024, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage" + }, + "7": { + "inputs": { + "seed": "__SEED__", + "steps": 4, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1, + "model": [ + "3", + 0 + ], + "positive": [ + "4", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "6", + 0 + ] + }, + "class_type": "KSampler" + }, + "8": { + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "9": { + "inputs": { + "filename_prefix": "Vendoo/flux_schnell", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..568d33f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2140 @@ +{ + "name": "vendoo", + "version": "1.36.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vendoo", + "version": "1.36.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.11.1", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e7c5561 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "vendoo", + "version": "1.36.0", + "type": "module", + "private": true, + "scripts": { + "start": "node bootstrap.mjs", + "dev": "node --watch bootstrap.mjs", + "check": "node --check bootstrap.mjs && node --check server.mjs && node --check lib/config-store.mjs && node --check lib/runtime-paths.mjs && node --check lib/db.mjs && node --check lib/auth.mjs && node --check lib/security.mjs && node --check lib/operations-center.mjs && node --check lib/images.mjs && node --check lib/ai-local-images.mjs && node --check lib/ai-model-photos.mjs && node --check lib/ai-flux-prompt-jobs.mjs && node --check lib/image-editor.mjs && node --check lib/image-batch-jobs.mjs && node --check lib/quality-center.mjs && node --check public/app.js && node --check public/design-system/theme-runtime.js && node --check public/core/safe-dom.js && node --check public/core/frontend-module-registry.js && node --check public/core/api-client.js && node --check app/kernel/errors.mjs && node --check app/kernel/event-bus.mjs && node --check app/kernel/feature-flags.mjs && node --check app/kernel/module-contract.mjs && node --check app/kernel/module-registry.mjs && node --check app/kernel/policy-engine.mjs && node --check app/kernel/route-registry.mjs && node --check app/kernel/lifecycle.mjs && node --check app/kernel/platform-kernel.mjs && node --check app/modules/catalog.mjs && node --check app/core/themes/theme-contract.mjs && node --check tools/generate-design-tokens.mjs && node --check tools/verify-platform-architecture-1.35.0.mjs && node --check tools/verify-config-store.mjs && node --check tools/verify-git-safety.mjs && node --check tools/verify-deployment-1.35.0.mjs && node --check tools/verify-git-safety-regression.mjs && node --check tools/prepare-git-staging.mjs && node --check tools/verify-git-staging.mjs && node --check app/modules/themes/index.mjs && node --check app/modules/themes/service.mjs && node --check app/modules/themes/repository.mjs && node --check public/modules/theme-manager/theme-manager.js && node --check tools/verify-theme-manager-1.36.0.mjs && node --check tools/verify-platform-architecture-1.36.0.mjs && node --check tools/verify-deployment-1.36.0.mjs", + "verify:slice30": "node tools/verify-slice30-mock.mjs", + "verify:db": "python tools/verify-db-migrations.py", + "verify:slice31": "node tools/verify-slice31-image-editor.mjs", + "verify:hotfix": "node tools/verify-release-1.25.1.mjs", + "verify:gallery": "node tools/verify-release-1.25.1-static.mjs", + "verify:release": "node tools/verify-deployment-1.36.0.mjs", + "verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs", + "verify:deployment": "node tools/verify-deployment-1.36.0.mjs", + "verify:git": "node tools/verify-git-safety.mjs", + "verify:config": "node tools/verify-config-store.mjs", + "verify:installer-shell": "sh tools/verify-installer-shell.sh", + "verify:git-regression": "node tools/verify-git-safety-regression.mjs", + "verify:git-staging": "node tools/verify-git-staging.mjs", + "generate:tokens": "node tools/generate-design-tokens.mjs", + "verify:architecture": "node tools/verify-platform-architecture-1.36.0.mjs", + "verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run check && npm run verify:architecture && npm run verify:themes && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db", + "verify:themes": "node tools/verify-theme-manager-1.36.0.mjs" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^12.11.1", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", + "openai": "^4.77.0", + "sharp": "^0.33.5" + } +} diff --git a/platforms/ebay-de.mjs b/platforms/ebay-de.mjs new file mode 100644 index 0000000..7895862 --- /dev/null +++ b/platforms/ebay-de.mjs @@ -0,0 +1,45 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'eBay.de', + id: 'ebay-de', + fields: ['title', 'description', 'tags'], + maxTitleLength: 80, + maxTags: 0, + feePercent: 11, + publishUrl: 'https://www.ebay.de/sell/create', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay.de-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 80 Zeichen). Marke, Artikelart, Größe, Farbe, Zustand. Nutze gängige eBay-Suchbegriffe. +- **description**: Professionelle Artikelbeschreibung (5-8 Sätze). Material, Zustand detailliert, Maße, Besonderheiten, Versandhinweise. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Smartphone") +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Neu mit Etikett / Wie neu / Gut / Akzeptabel / Für Bastler) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und aktuelle eBay-Marktpreise + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/platforms/ebay-ka.mjs b/platforms/ebay-ka.mjs new file mode 100644 index 0000000..e9da28e --- /dev/null +++ b/platforms/ebay-ka.mjs @@ -0,0 +1,39 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', +}; + +export default { + name: 'eBay Kleinanzeigen', + id: 'ebay-ka', + fields: ['title', 'description', 'tags'], + maxTitleLength: 65, + maxTags: 0, + feePercent: 0, + shippingNote: 'eBay KA erhebt keine Verkaufsgebühren. Versandkosten variieren.', + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener eBay-Kleinanzeigen-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten einer Anzeige' : 'eine Anzeige'}. + +Für jede Variante liefere: +- **title**: Prägnanter Titel (max. 65 Zeichen). Marke, Artikelart, Größe/Maße, Zustand. +- **description**: Ausführliche Beschreibung (5-8 Sätze). Zustand detailliert, Material, Maße, Abholung/Versand. +- **tags**: Leeres Array []. + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu / Wie neu / Gut / Akzeptabel / Defekt) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":[]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":[], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/platforms/etsy.mjs b/platforms/etsy.mjs new file mode 100644 index 0000000..9b317ec --- /dev/null +++ b/platforms/etsy.mjs @@ -0,0 +1,34 @@ +export default { + name: 'Etsy', + id: 'etsy', + fields: ['title', 'description', 'tags'], + maxTitleLength: 140, + maxTags: 13, + feePercent: 6.5, + listingFee: 0.20, + + buildPrompt(sellerNotes, { language = 'en', variants = 1 } = {}) { + const langNote = language === 'en' ? 'Write in English.' : language === 'de' ? 'Schreibe auf Deutsch.' : 'Write in English.'; + return `Du bist ein erfahrener Etsy-Verkäufer. ${langNote} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 140 Zeichen). Keywords, Marke, Material, Stil. +- **description**: Ansprechende Beschreibung (4-6 Sätze). Material, Besonderheiten, Maße, Zustand. Freundlicher Ton. +- **tags**: 13 relevante Tags (kurze Phrasen, OHNE #). + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unknown"/"Unbekannt") +- **category**: Artikelkategorie +- **size**: Größe falls erkennbar +- **color**: Hauptfarbe(n) +- **condition**: Zustand (New / Like new / Good / Fair) +- **suggested_price**: Geschätzter Verkaufspreis in EUR + +${sellerNotes ? `Verkäufernotizen (einbauen, ggf. übersetzen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","tags":["..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","tags":["..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/platforms/vinted.mjs b/platforms/vinted.mjs new file mode 100644 index 0000000..d0cbd2f --- /dev/null +++ b/platforms/vinted.mjs @@ -0,0 +1,44 @@ +const LANG_INSTRUCTIONS = { + de: 'Schreibe auf Deutsch.', + en: 'Write in English.', + fr: 'Écris en français.', + nl: 'Schrijf in het Nederlands.', + pl: 'Pisz po polsku.', + es: 'Escribe en español.', + it: 'Scrivi in italiano.', + pt: 'Escreve em português.', +}; + +export default { + name: 'Vinted', + id: 'vinted', + fields: ['title', 'description', 'hashtags'], + maxTitleLength: 100, + maxTags: 5, + feePercent: 5, + + buildPrompt(sellerNotes, { language = 'de', variants = 1 } = {}) { + const lang = LANG_INSTRUCTIONS[language] || LANG_INSTRUCTIONS.de; + return `Du bist ein erfahrener Vinted-Verkäufer. ${lang} + +Analysiere die Produktfotos und erstelle ${variants > 1 ? variants + ' verschiedene Varianten eines Listings' : 'ein Listing'}. + +Für jede Variante liefere: +- **title**: SEO-optimierter Titel (max. 100 Zeichen). Nenne Marke, Größe, Farbe, Artikelart. +- **description**: Verkaufsfördernde Beschreibung (3-5 Sätze). Material, Zustand, Besonderheiten. +- **hashtags**: Genau 5 relevante Hashtags mit # (z.B. #vintage #nike #sommer) + +Zusätzlich erkenne aus den Fotos: +- **brand**: Markenname (oder "Unbekannt") +- **category**: Artikelkategorie (z.B. "Sneaker", "Jacke", "Kleid") +- **size**: Größe falls erkennbar (z.B. "M", "42", "38") +- **color**: Hauptfarbe(n) +- **condition**: Zustand (Neu mit Etikett / Neu ohne Etikett / Sehr gut / Gut / Befriedigend) +- **suggested_price**: Geschätzter Verkaufspreis in EUR basierend auf Marke, Zustand und Typ + +${sellerNotes ? `Verkäufernotizen (in Beschreibung einbauen):\n${sellerNotes}\n` : ''} +Antworte als JSON: ${variants > 1 + ? '{"variants": [{"title":"...","description":"...","hashtags":["#..."]}, ...], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}' + : '{"title":"...","description":"...","hashtags":["#..."], "brand":"...", "category":"...", "size":"...", "color":"...", "condition":"...", "suggested_price": 0}'}`; + }, +}; diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..10a83d7 --- /dev/null +++ b/public/app.js @@ -0,0 +1,9668 @@ +const VENDOO_UI_BUILD = '1.36.0'; + +const state = { + photos: [], + platform: '', + aiProvider: 'claude', + aiModel: '', + language: 'de', + variants: 1, + currentListing: null, + allVariants: null, + platforms: [], + providers: [], + templates: [], + editingTemplateId: null, + historyPage: 1, + historyPageSize: 10, + publishPage: 1, + publishPageSize: 10, + aiModelPhotos: [], + aiModelGenerations: [], + aiPromptScope: 'generator', + aiPromptDrafts: { + generator: { customPrompt: '', preserveLogos: true }, + detail: { customPrompt: '', preserveLogos: true }, + }, + fluxStudioCurrent: null, + fluxStudioHistory: [], + fluxHistoryPage: 1, + fluxHistoryPageSize: 5, + fluxHistoryPagination: { page: 1, total_pages: 1, total: 0 }, + fluxStudioBusy: false, + fluxJobs: [], + fluxJobSummary: null, + fluxRuntime: null, + fluxStudioWatchers: new Map(), + fluxJobExpanded: new Set(), + fluxJobManuallyToggled: new Set(), + fluxJobCenterCollapsed: false, + fluxJobArchiveMode: false, + fluxJobPage: 1, + fluxJobPageSize: 10, + fluxJobPagination: { page: 1, total_pages: 1, total: 0 }, + fluxJobSelected: new Set(), + mediaItems: [], + mediaSelected: new Set(), + mediaPage: 1, + mediaPageSize: 24, + mediaPagination: { page: 1, total_pages: 1, total: 0 }, + mediaSummary: null, + mediaView: 'grid', + mediaCurrent: null, + imageFactoryProfiles: [], + imageFactorySources: [], + imageFactorySelectedSources: new Set(), + imageFactorySelectedProfiles: new Set(), + imageFactoryJobs: [], + imageFactoryJobPage: 1, + imageFactoryJobPageSize: 10, + imageFactoryJobPagination: { page: 1, total_pages: 1, total: 0 }, + imageFactorySummary: null, + imageFactoryExpanded: new Set(), + qualityItems: [], + qualityPage: 1, + qualityPageSize: 10, + qualityPagination: { page: 1, total_pages: 1, total: 0 }, + qualitySummary: null, + qualityCurrent: null, + qualityIssueFilter: 'all', + qualityAiDraft: null, +}; + +const editors = {}; + +const htmlSyncState = { generatorLock: false, detailLock: false }; +let detailEditorState = null; +const listingEditLock = { listingId: null, token: null, lock: null, timer: null }; +const mobileUploadState = { session: null, pollTimer: null, target: null, imported: new Set() }; + + +const QUILL_TOOLBAR = [ + [{ header: [1, 2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ color: [] }, { background: [] }], + [{ list: 'ordered' }, { list: 'bullet' }], + [{ align: [] }], + ['blockquote', 'link'], + ['clean'], +]; + +function initQuill(container, placeholder) { + if (typeof Quill === 'undefined') return null; + return new Quill(container, { + theme: 'snow', + placeholder: placeholder || '', + modules: { toolbar: QUILL_TOOLBAR }, + }); +} + +const RICH_ALLOWED_TAGS = new Set([ + 'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'OL', 'UL', 'LI', + 'BLOCKQUOTE', 'A', 'H1', 'H2', 'H3', 'SPAN', 'DIV', 'SUB', 'SUP', 'CODE', 'PRE', +]); +const RICH_DANGEROUS_TAGS = new Set([ + 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', + 'SELECT', 'OPTION', 'SVG', 'MATH', 'VIDEO', 'AUDIO', 'LINK', 'META', +]); + +function sanitizeStyleClient(style) { + return String(style || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf(':'); + if (index < 1) return ''; + const name = part.slice(0, index).trim().toLowerCase(); + const value = part.slice(index + 1).trim(); + if (!['color', 'background-color', 'text-align'].includes(name)) return ''; + if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return ''; + if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) return ''; + if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) return ''; + return `${name}:${value}`; + }).filter(Boolean).join(';'); +} + +function sanitizeRichHtmlClient(input) { + const doc = new DOMParser().parseFromString(`${String(input || '')}`, 'text/html'); + const elements = [...doc.body.querySelectorAll('*')].reverse(); + for (const element of elements) { + if (RICH_DANGEROUS_TAGS.has(element.tagName)) { + element.remove(); + continue; + } + if (!RICH_ALLOWED_TAGS.has(element.tagName)) { + element.replaceWith(...element.childNodes); + continue; + } + for (const attr of [...element.attributes]) { + const name = attr.name.toLowerCase(); + const value = attr.value; + let keep = false; + if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:|tel:|#|\/)/i.test(value.trim())) keep = true; + if (element.tagName === 'A' && name === 'target' && ['_blank', '_self'].includes(value)) keep = true; + if (element.tagName === 'A' && name === 'rel') { element.setAttribute('rel', 'noopener noreferrer'); keep = true; } + if (name === 'style') { + const safeStyle = sanitizeStyleClient(value); + if (safeStyle) { element.setAttribute('style', safeStyle); keep = true; } + } + if (name === 'class' && String(value).split(/\s+/).every(cls => /^ql-[a-z0-9-]+$/i.test(cls))) keep = true; + if (!keep) element.removeAttribute(attr.name); + } + if (element.tagName === 'A' && element.getAttribute('target') === '_blank') element.setAttribute('rel', 'noopener noreferrer'); + } + return doc.body.innerHTML.trim(); +} + +function plainTextToRichHtml(text) { + const value = String(text || '').trim(); + if (!value) return ''; + return value.split(/\n{2,}/).map(paragraph => `

                                                                      ${escapeHtml(paragraph).replace(/\n/g, '
                                                                      ')}

                                                                      `).join(''); +} + +function richHtmlToText(html) { + const container = document.createElement('div'); + container.innerHTML = sanitizeRichHtmlClient(html); + container.querySelectorAll('br').forEach(br => br.replaceWith('\n')); + container.querySelectorAll('p,div,h1,h2,h3,li,blockquote,pre').forEach(node => node.append('\n')); + return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); +} + +function getEditorHtml(editor) { + if (!editor) return ''; + const html = sanitizeRichHtmlClient(editor.root.innerHTML); + return /^(


                                                                      <\/p>|

                                                                      <\/p>)$/i.test(html) ? '' : html; +} + +function setEditorHtml(editor, html) { + if (!editor) return; + const safe = sanitizeRichHtmlClient(html); + editor.setText(''); + if (safe) editor.clipboard.dangerouslyPasteHTML(safe); +} + +function setSandboxPreview(frame, html) { + if (!frame) return; + frame.srcdoc = `${String(html || '')}`; +} + +function setHtmlSyncStatus(id, text, tone = '') { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.dataset.tone = tone; +} + +const clientDiagnostics = { incidents: [], lastSystemReport: null, online: true, healthTimer: null }; + +function recordClientIncident(type, message, details = {}) { + const incident = { + at: new Date().toISOString(), + type: String(type || 'client'), + message: String(message || 'Unbekannter Fehler').slice(0, 500), + view: document.querySelector('.tab-content.active')?.id || 'unknown', + build: VENDOO_UI_BUILD, + ...details, + }; + clientDiagnostics.incidents.unshift(incident); + clientDiagnostics.incidents = clientDiagnostics.incidents.slice(0, 30); + try { localStorage.setItem('vendoo_client_incidents', JSON.stringify(clientDiagnostics.incidents)); } catch {} + return incident; +} + +function setConnectionState(online, detail = '') { + clientDiagnostics.online = !!online; + const banner = document.getElementById('connection-banner'); + const title = document.getElementById('connection-banner-title'); + const description = document.getElementById('connection-banner-detail'); + if (!banner) return; + banner.classList.toggle('hidden', online); + banner.classList.toggle('is-online', online); + if (title) title.textContent = online ? 'Verbindung wiederhergestellt' : 'Vendoo ist nicht erreichbar'; + if (description) description.textContent = detail || (online ? 'Die Anwendung ist wieder verbunden.' : 'Verbindung wird automatisch erneut geprüft.'); +} + +async function checkVendooHealth({ quiet = true } = {}) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 3500); + try { + const response = await fetch(`/healthz?t=${Date.now()}`, { cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + const wasOffline = !clientDiagnostics.online; + setConnectionState(true, `Server erreichbar · Version ${result.version || VENDOO_UI_BUILD}`); + if (wasOffline && !quiet) toast('Verbindung zu Vendoo wiederhergestellt.'); + return true; + } catch (error) { + setConnectionState(false, error?.name === 'AbortError' ? 'Zeitüberschreitung beim Serverzugriff.' : 'Serververbindung unterbrochen. Automatische Wiederholung läuft.'); + return false; + } finally { + window.clearTimeout(timer); + } +} + +function setupGlobalResilience() { + try { + const stored = JSON.parse(localStorage.getItem('vendoo_client_incidents') || '[]'); + if (Array.isArray(stored)) clientDiagnostics.incidents = stored.slice(0, 30); + } catch {} + window.addEventListener('error', event => { + recordClientIncident('javascript', event.message, { file: event.filename || '', line: event.lineno || 0, column: event.colno || 0 }); + }); + window.addEventListener('unhandledrejection', event => { + const reason = event.reason; + recordClientIncident('promise', reason?.message || String(reason || 'Unbehandelte Promise-Ablehnung')); + }); + window.addEventListener('offline', () => setConnectionState(false, 'Der Browser meldet keine Netzwerkverbindung.')); + window.addEventListener('online', () => checkVendooHealth({ quiet: false })); + document.getElementById('connection-retry-btn')?.addEventListener('click', () => checkVendooHealth({ quiet: false })); + checkVendooHealth(); + clientDiagnostics.healthTimer = window.setInterval(() => checkVendooHealth(), 30000); + window.addEventListener('beforeunload', () => window.clearInterval(clientDiagnostics.healthTimer), { once: true }); +} + +function applyResponsiveContract() { + const main = document.querySelector('.main-wrapper'); + if (!main) return; + const width = Math.round(main.getBoundingClientRect().width); + const bucket = width < 560 ? 'phone' : width < 820 ? 'narrow' : width < 1120 ? 'compact' : width < 1480 ? 'standard' : 'wide'; + main.dataset.layoutWidth = bucket; + main.style.setProperty('--workspace-width', `${width}px`); + document.querySelectorAll('.tab-content').forEach(section => { + section.dataset.layoutWidth = bucket; + }); +} + +function setupResponsiveContracts() { + applyResponsiveContract(); + const main = document.querySelector('.main-wrapper'); + if (main && 'ResizeObserver' in window) { + const observer = new ResizeObserver(() => window.requestAnimationFrame(applyResponsiveContract)); + observer.observe(main); + window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); + } else { + window.addEventListener('resize', debounce(applyResponsiveContract, 100)); + } +} + +async function init() { + document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD; + console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`); + setupGlobalResilience(); + setupResponsiveContracts(); + // Auth check — redirect to login if not authenticated or setup needed + try { + const meCheck = await fetch('/api/me', { credentials: 'same-origin' }); + if (meCheck.status === 401) { window.location.href = '/login.html'; return; } + const meData = await meCheck.json(); + if (meData.setupMode) { window.location.href = '/login.html'; return; } + } catch {} + + const [platforms, settings, providers, templates] = await Promise.all([ + api('GET', '/api/platforms'), + api('GET', '/api/settings'), + api('GET', '/api/providers'), + api('GET', '/api/templates'), + ]); + + state.platforms = platforms; + state.providers = providers; + state.templates = templates; + state.platform = settings.default_platform || 'vinted'; + state.aiProvider = settings.default_ai || 'claude'; + state.language = settings.default_language || 'de'; + + renderPlatformButtons(); + renderProviderButtons(); + populateSettingsForm(settings, platforms, providers); + populateTemplateSelects(platforms); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('language-select').value = state.language; + + editors.result = initQuill('#result-description-editor', 'Beschreibung wird hier angezeigt...'); + editors.tplNotes = initQuill('#tpl-notes-editor', 'Verkäufernotizen...'); + + setupGeneratorHtmlSync(); + setupHtmlViewSwitches(); + setupMobileUpload(); + + addQuillTooltips(); + + setupTabs(); + setupAnalyticsDashboard(); + setupTodayFlow(); + setupUpload(); + setupImageTools(); + setupGenerate(); + setupGeneratorStudio(); + setupAiPromptModal(); + setupFluxStudio(); + setupMediaLibrary(); + setupImageFactory(); + setupQualityCenter(); + setupExtensions(); + setupCopy(); + setupHistory(); + setupPublish(); + setupTrash(); + setupTemplates(); + setupFeeCalculator(); + setupSettings(); + setupDarkMode(); + setupPhotoEditor(); + setupBackup(); + setupNotifications(); + setupUserMenu(); + setupAdmin(); + loadAnalyticsDashboard(); + updateTrashBadge(); +} + +// --- API --- +let _csrfToken = ''; +function getCsrf() { + if (_csrfToken) return _csrfToken; + const m = document.cookie.match(/csrf_token=([^;]+)/); + return m ? m[1] : ''; +} +async function api(method, url, body, options = {}) { + const verb = String(method || 'GET').toUpperCase(); + const attempts = verb === 'GET' && options.retry !== false ? 2 : 1; + const timeoutMs = Number(options.timeoutMs || (verb === 'GET' ? 20000 : 90000)); + const requestId = globalThis.crypto?.randomUUID?.() || `vd-${Date.now()}-${Math.random().toString(16).slice(2)}`; + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + const opts = { method: verb, headers: { 'X-Vendoo-Request-ID': requestId, ...(options.headers || {}) }, credentials: 'same-origin', signal: controller.signal }; + if (body !== undefined && body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + if (!['GET', 'HEAD'].includes(verb)) opts.headers['X-CSRF-Token'] = getCsrf(); + try { + const res = await fetch(url, opts); + if (res.status === 401) { window.location.href = '/login.html'; return; } + const contentType = res.headers.get('content-type') || ''; + const payload = contentType.includes('application/json') + ? await res.json().catch(() => ({})) + : await res.text().catch(() => ''); + if (!res.ok) { + const error = new Error(payload?.error || payload?.message || (typeof payload === 'string' && payload) || res.statusText || 'Fehler'); + error.status = res.status; + error.requestId = requestId; + if (attempt < attempts && res.status >= 500) { lastError = error; continue; } + throw error; + } + if (!clientDiagnostics.online) setConnectionState(true, 'Serververbindung wiederhergestellt.'); + return payload; + } catch (error) { + lastError = error?.name === 'AbortError' ? new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`) : error; + lastError.requestId = requestId; + const retryable = attempt < attempts && (error?.name === 'AbortError' || error instanceof TypeError || Number(error?.status || 0) >= 500); + if (retryable) { await new Promise(resolve => window.setTimeout(resolve, 250)); continue; } + recordClientIncident('api', lastError.message, { method: verb, url, requestId, status: lastError.status || 0 }); + if (error instanceof TypeError || error?.name === 'AbortError') setConnectionState(false, `${lastError.message} · Anfrage ${requestId.slice(0, 8)}`); + throw lastError; + } finally { + window.clearTimeout(timer); + } + } + throw lastError || new Error('Anfrage fehlgeschlagen'); +} + + +function aiModelStatusClass(tone = 'neutral') { + return `ai-model-status ${tone ? `is-${tone}` : ''}`.trim(); +} + +function setAiModelStatus(elementId, message, tone = 'neutral') { + const element = document.getElementById(elementId); + if (!element) return; + element.textContent = message; + element.className = aiModelStatusClass(tone); +} + + +function setAiModelJobProgress(elementId, job) { + const element = document.getElementById(elementId); + if (!element) return; + const progress = Math.max(0, Math.min(100, Number(job?.progress || 0))); + const message = job?.status === 'failed' + ? (job.error_message || 'AI-Bildjob fehlgeschlagen') + : (job?.progress_message || formatAiJobStatus(job)); + const tone = job?.status === 'failed' ? 'danger' : job?.status === 'completed' ? 'success' : 'working'; + element.className = `${aiModelStatusClass(tone)} ai-job-progress-card`; + element.innerHTML = ` +

                                                                      ${escapeHtml(message)}${progress}%
                                                                      +
                                                                      + ${job?.status === 'queued' ? 'Warteschlange' : job?.status === 'running' ? 'Lokale Verarbeitung läuft' : job?.status === 'completed' ? 'Abgeschlossen' : 'Aktion beendet'} + `; +} + +function getGeneratorAiModelPayload() { + return { + photos: [...state.photos], + platform: state.platform, + title: document.getElementById('result-title')?.value || state.currentListing?.title || '', + category: metaCategoryPicker?.getValue?.() || state.currentListing?.category || '', + brand: document.getElementById('meta-brand')?.value || state.currentListing?.brand || '', + color: document.getElementById('meta-color')?.value || state.currentListing?.color || '', + size: document.getElementById('meta-size')?.value || state.currentListing?.size || '', + condition: document.getElementById('meta-condition')?.value || state.currentListing?.condition || '', + seller_notes: document.getElementById('generator-seller-notes')?.value || state.currentListing?.seller_notes || '', + mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('generator-ai-model-variants')?.value || '1', 10), + }; +} + +function buildAiModelCardHtml(items, scope) { + const prefix = scope === 'detail' ? 'detail' : 'generator'; + return ` +
                                                                      + ${items.length} sichere Bildvariante${items.length === 1 ? '' : 'n'} + +
                                                                      +
                                                                      + ${items.map((photo, index) => ` +
                                                                      + Produktbild-Variante ${index + 1} +
                                                                      + Variante ${photo.variant_index || index + 1} + ${escapeHtml((photo.mode || 'flatlay').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())} +
                                                                      + +
                                                                      `).join('')} +
                                                                      `; +} + + +function renderAiModelGrid(scope, items = []) { + const grid = document.getElementById(scope === 'detail' ? 'detail-ai-model-grid' : 'generator-ai-model-grid'); + if (!grid) return; + if (!items.length) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + return; + } + grid.innerHTML = buildAiModelCardHtml(items, scope); + grid.classList.remove('hidden'); + + const addOne = filename => { + if (scope === 'detail') { + if (!detailEditorState) return; + if (!detailEditorState.photos.includes(filename)) detailEditorState.photos.push(filename); + detailEditorState.activePhotoIndex = detailEditorState.photos.length - 1; + renderDetailPhotos(); + } else { + if (!state.photos.includes(filename)) state.photos.push(filename); + renderPhotoPreviews(); + updateGenerateButton(); + } + toast('AI-Model-Foto hinzugefügt'); + }; + + grid.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => addOne(button.dataset.aiModelAddOne))); + grid.querySelector('[data-ai-model-add-all]')?.addEventListener('click', () => { + items.forEach(photo => addOne(photo.image_path || photo.filename)); + toast('Alle AI-Model-Fotos hinzugefügt'); + }); +} + +async function pollAiModelJob(jobId, onProgress) { + const started = Date.now(); + while (Date.now() - started < 360000) { + const job = await api('GET', `/api/ai-model-photos/jobs/${jobId}`); + if (typeof onProgress === 'function') onProgress(job); + if (job.status === 'completed' || job.status === 'failed') return job; + await new Promise(resolve => setTimeout(resolve, 1800)); + } + throw new Error('Zeitüberschreitung bei der AI-Bildgenerierung'); +} + +function formatAiJobStatus(job) { + if (!job) return 'AI-Bildjob wird vorbereitet …'; + if (job.status === 'queued') return job.progress_message || 'AI-Bildjob steht in der Warteschlange …'; + if (job.status === 'running') return job.progress_message || `AI-Bilder werden erzeugt … ${job.progress || 1}%`; + if (job.status === 'failed') return job.error_message || 'AI-Bildjob fehlgeschlagen'; + return job.progress_message || 'AI-Bildjob abgeschlossen'; +} + + +function getAiPromptDraft(scope = 'generator') { + if (!state.aiPromptDrafts[scope]) state.aiPromptDrafts[scope] = { customPrompt: '', preserveLogos: true }; + return state.aiPromptDrafts[scope]; +} + +function getAiPromptContext(scope = 'generator') { + if (scope === 'detail') { + return { + mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed', + variants: document.getElementById('detail-ai-model-variants')?.value || '1', + }; + } + return { + mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed', + variants: document.getElementById('generator-ai-model-variants')?.value || '1', + }; +} + +function updateAiPromptSummary(scope = 'generator') { + const ctx = getAiPromptContext(scope); + const modeLabel = ctx.mode === 'ghost_mannequin' ? 'Ghost Mannequin' : 'Flatlay'; + const lookLabel = ctx.preset === 'mixed' ? 'Mix' : ctx.preset === 'studio' ? 'Studio' : ctx.preset === 'indoor' ? 'Indoor' : 'Outdoor'; + document.getElementById('ai-prompt-mode-chip').textContent = `Modus: ${modeLabel}`; + document.getElementById('ai-prompt-look-chip').textContent = `Look: ${lookLabel}`; + document.getElementById('ai-prompt-variants-chip').textContent = `Varianten: ${ctx.variants}`; + const logoCheck = document.getElementById('ai-prompt-preserve-logos'); + if (logoCheck) { + logoCheck.disabled = false; + } +} + +function closeAiPromptModal() { + const modal = document.getElementById('ai-prompt-modal'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('ai-prompt-open'); +} + +function syncAiPromptDraftFromModal() { + const scope = state.aiPromptScope || 'generator'; + const draft = getAiPromptDraft(scope); + draft.customPrompt = document.getElementById('ai-prompt-textarea')?.value || ''; + draft.preserveLogos = document.getElementById('ai-prompt-preserve-logos')?.checked !== false; +} + +function openAiPromptModal(scope = 'generator') { + const modal = document.getElementById('ai-prompt-modal'); + if (!modal) return; + state.aiPromptScope = scope; + const draft = getAiPromptDraft(scope); + document.getElementById('ai-prompt-scope-copy').textContent = scope === 'detail' ? 'Zusatzanweisung für das aktuelle Listing' : 'Zusatzanweisung für den Generator'; + document.getElementById('ai-prompt-textarea').value = draft.customPrompt || ''; + document.getElementById('ai-prompt-preserve-logos').checked = draft.preserveLogos !== false; + updateAiPromptSummary(scope); + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('ai-prompt-open'); + setTimeout(() => document.getElementById('ai-prompt-textarea')?.focus(), 30); +} + +function setupAiPromptModal() { + const modal = document.getElementById('ai-prompt-modal'); + if (!modal) return; + modal.querySelectorAll('[data-ai-prompt-close]').forEach(btn => btn.addEventListener('click', closeAiPromptModal)); + document.getElementById('ai-prompt-textarea')?.addEventListener('input', syncAiPromptDraftFromModal); + document.getElementById('ai-prompt-preserve-logos')?.addEventListener('change', syncAiPromptDraftFromModal); + document.getElementById('ai-prompt-reset-btn')?.addEventListener('click', () => { + const draft = getAiPromptDraft(state.aiPromptScope || 'generator'); + draft.customPrompt = ''; + draft.preserveLogos = true; + document.getElementById('ai-prompt-textarea').value = ''; + document.getElementById('ai-prompt-preserve-logos').checked = true; + }); + document.getElementById('ai-prompt-run-btn')?.addEventListener('click', async () => { + syncAiPromptDraftFromModal(); + const scope = state.aiPromptScope || 'generator'; + closeAiPromptModal(); + if (scope === 'detail') await runDetailAiModelPhotos(); + else await runGeneratorAiModelPhotos(); + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !modal.classList.contains('hidden')) closeAiPromptModal(); + }); +} + + + +const FLUX_PROMPT_TEMPLATES = { + product: 'Professionelle Produktfotografie, klares Hauptmotiv, neutraler Studiohintergrund, weiches Licht, realistische Materialien, hochwertige kommerzielle Bildsprache', + social: 'Modernes Social-Media-Motiv, klarer visueller Fokus, starke aber ausgewogene Komposition, hochwertige Beleuchtung, sofort verständlich', + editorial: 'Premium Editorial-Fotografie, anspruchsvolle Magazinkomposition, kontrolliertes Licht, elegante Farbwelt, hochwertige realistische Details', + background: 'Minimalistischer heller Studiohintergrund, weiche natürliche Schatten, dezente Tiefe, sauber, hochwertig, ohne Text und ohne Personen', + lifestyle: 'Authentische hochwertige Lifestyle-Szene, natürliches Tageslicht, glaubwürdige Umgebung, harmonische Komposition, fotorealistisch', + banner: 'Breites hochwertiges Werbemotiv, klarer freier Bereich für spätere Texte, kontrollierte Beleuchtung, moderne kommerzielle Gestaltung', +}; + +const FLUX_PROFILE_PRESETS = { + fast: { format: '768x768', steps: '4' }, + balanced: { format: '1024x1024', steps: '4' }, + quality: { format: '1024x1024', steps: '6' }, + custom: null, +}; + +const FLUX_TERMINAL_JOB_STATUSES = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled']); +const FLUX_ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']); + +function analyzeFluxPrompt(value = '') { + const text = String(value || '').trim(); + if (!text) return { level: 'empty', copy: 'Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.' }; + const words = text.split(/\s+/).filter(Boolean).length; + const signals = [ + /licht|lighting|beleuchtung/i, + /hintergrund|background|umgebung|scene|studio/i, + /kamera|camera|perspektive|perspective|close-up|nahaufnahme|wide shot/i, + /farbe|color|material|texture|oberfläche/i, + ].filter(pattern => pattern.test(text)).length; + if (words < 8) return { level: 'weak', copy: 'Noch sehr kurz. Ergänze Motiv, Umgebung, Licht und Bildausschnitt.' }; + if (words < 18 || signals < 2) return { level: 'good', copy: 'Gute Basis. Der Prompt-Assistent kann Komposition und technische Bildsprache ergänzen.' }; + return { level: 'strong', copy: 'Starker Prompt mit ausreichend visueller Führung.' }; +} + +function refreshFluxPromptQuality() { + const input = document.getElementById('flux-studio-prompt'); + const box = document.getElementById('flux-prompt-quality'); + const copy = document.getElementById('flux-prompt-quality-copy'); + if (!box || !copy) return; + const result = analyzeFluxPrompt(input?.value || ''); + box.dataset.level = result.level; + copy.textContent = result.copy; +} + +function optimizeFluxPromptLocally(value = '') { + let text = String(value || '').trim().replace(/\s+/g, ' '); + if (!text) return text; + const additions = []; + if (!/licht|lighting|beleuchtung/i.test(text)) additions.push('weiches gerichtetes Licht mit realistischen Schatten'); + if (!/kamera|camera|perspektive|perspective|aufnahme|shot/i.test(text)) additions.push('klarer professioneller Bildausschnitt auf Augenhöhe'); + if (!/hintergrund|background|umgebung|scene|studio/i.test(text)) additions.push('ruhige, zum Motiv passende Umgebung'); + if (!/detail|material|texture|oberfläche/i.test(text)) additions.push('realistische Materialien und feine Oberflächendetails'); + if (additions.length) text = `${text.replace(/[,. ]+$/, '')}, ${additions.join(', ')}`; + return text; +} + +function applyFluxProfile(profile, { persist = true } = {}) { + const normalized = FLUX_PROFILE_PRESETS[profile] ? profile : 'custom'; + const hidden = document.getElementById('flux-studio-profile'); + if (hidden) hidden.value = normalized; + document.querySelectorAll('[data-flux-profile]').forEach(button => button.classList.toggle('active', button.dataset.fluxProfile === normalized)); + const preset = FLUX_PROFILE_PRESETS[normalized]; + if (preset) { + const format = document.getElementById('flux-studio-format'); + const steps = document.getElementById('flux-studio-steps'); + if (format) format.value = preset.format; + if (steps) steps.value = preset.steps; + } + if (persist) { try { localStorage.setItem('vendoo-flux-profile', normalized); } catch {} } +} + +function setFluxVariantCount(value, { persist = true } = {}) { + const normalized = [1, 2, 4].includes(Number(value)) ? Number(value) : 1; + const input = document.getElementById('flux-studio-variants'); + if (input) input.value = String(normalized); + document.querySelectorAll('[data-flux-variants]').forEach(button => button.classList.toggle('active', Number(button.dataset.fluxVariants) === normalized)); + if (persist) { try { localStorage.setItem('vendoo-flux-variants', String(normalized)); } catch {} } +} + +function resetFluxStudioWorkspace({ clearSavedDefaults = false, announce = false } = {}) { + state.fluxStudioCurrent = null; + renderFluxStudioPreview(null); + const variants = document.getElementById('flux-studio-variant-results'); + if (variants) { variants.innerHTML = ''; variants.classList.add('hidden'); } + + const prompt = document.getElementById('flux-studio-prompt'); + if (prompt) { prompt.value = ''; prompt.dispatchEvent(new Event('input')); } + const mode = document.getElementById('flux-studio-prompt-mode'); + if (mode) mode.value = 'precise'; + const style = document.getElementById('flux-studio-style'); + if (style) style.value = 'photorealistic'; + const seed = document.getElementById('flux-studio-seed'); + if (seed) seed.value = '42'; + const lock = document.getElementById('flux-studio-seed-lock'); + if (lock) lock.checked = false; + applyFluxProfile('balanced', { persist: !clearSavedDefaults }); + setFluxVariantCount(1, { persist: !clearSavedDefaults }); + if (clearSavedDefaults) { + try { + localStorage.removeItem('vendoo-flux-profile'); + localStorage.removeItem('vendoo-flux-variants'); + } catch {} + } + setFluxStudioStatus('Bereit. Noch keine Auswahl.', ''); + setFluxStudioProgress('', false); + renderFluxStudioHistory(); + if (announce) toast('FLUX Studio wurde auf einen leeren Arbeitsbereich zurückgesetzt. Verlauf und Bilder bleiben erhalten.'); +} + +function parseFluxFormat() { + const value = document.getElementById('flux-studio-format')?.value || '768x768'; + const [width, height] = value.split('x').map(Number); + return { width: width || 768, height: height || 768 }; +} + +function setFluxStudioStatus(message, tone = '') { + const status = document.getElementById('flux-studio-status'); + if (!status) return; + status.textContent = message; + status.dataset.tone = tone; +} + +function setFluxStudioProgress(label = 'Vorbereitung', visible = true, stateName = 'active') { + const shell = document.getElementById('flux-studio-progress'); + const value = document.getElementById('flux-studio-progress-value'); + const text = document.getElementById('flux-studio-progress-label'); + shell?.classList.toggle('hidden', !visible); + if (shell) shell.dataset.state = stateName; + if (value) value.textContent = stateName === 'done' ? 'Fertig' : stateName === 'error' ? 'Fehler' : stateName === 'cancelled' ? 'Abgebrochen' : 'Aktive Phase'; + if (text) text.textContent = label || 'FLUX arbeitet …'; +} + +function formatFluxDate(value) { + if (!value) return '–'; + const normalized = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }); +} + +function formatFluxDuration(ms) { + const seconds = Math.max(0, Math.round(Number(ms || 0) / 1000)); + if (seconds < 60) return `${seconds} s`; + return `${Math.floor(seconds / 60)} min ${seconds % 60} s`; +} + +function fluxStatusLabel(status) { + return ({ + queued: 'Wartend', running: 'Läuft', cancelling: 'Wird abgebrochen', completed: 'Erfolgreich', + completed_with_errors: 'Teilweise fertig', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', + })[status] || status || 'Unbekannt'; +} + +async function refreshFluxStudioEngineStatus() { + const chip = document.getElementById('flux-studio-engine-chip'); + try { + const status = await api('GET', '/api/local-image/status'); + const ready = !!status.reachable; + if (chip) { + chip.textContent = status.engine_label || (ready ? 'Aktiv und bereit' : 'Nicht bereit'); + chip.className = `settings-status-chip ${ready ? 'is-ready' : ['stopped','starting'].includes(status.engine_state) ? 'is-warning' : 'is-disabled'}`; + } + document.getElementById('flux-studio-generate')?.toggleAttribute('disabled', !ready || state.fluxStudioBusy); + return status; + } catch (error) { + if (chip) { chip.textContent = 'Status nicht verfügbar'; chip.className = 'settings-status-chip is-disabled'; } + return null; + } +} + +async function waitForFluxEngineReady({ statusElement = null, timeoutMs = 190000 } = {}) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const status = await refreshFluxStudioEngineStatus(); + if (status?.reachable) { + if (statusElement) statusElement.textContent = 'Lokaler FLUX/ComfyUI-Server ist aktiv und bereit.'; + return status; + } + const runtime = status?.runtime_state || {}; + const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + const detail = runtime.message || status?.message || 'ComfyUI wird initialisiert.'; + if (statusElement) statusElement.textContent = `FLUX startet seit ${elapsed}s · ${detail}`; + setFluxStudioStatus(`FLUX startet seit ${elapsed}s · ${detail}`, 'working'); + if (runtime.status === 'failed' || status?.engine_state === 'failed') { + const tail = [...(runtime.stderr_tail || []), ...(runtime.stdout_tail || [])].filter(Boolean).slice(-6).join(' | '); + throw new Error(runtime.error || tail || runtime.message || 'ComfyUI wurde während des Starts beendet.'); + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error('ComfyUI war nach 190 Sekunden noch nicht erreichbar. Prüfe die Runtime-Protokolle in Vendoo/logs.'); +} + +function fluxVariantToImage(job, variant) { + if (!variant?.image_path) return null; + return { + id: variant.image_id, + job_id: job.id, + variant_index: variant.variant_index, + prompt: job.prompt, + final_prompt: job.final_prompt, + image_path: variant.image_path, + public_url: variant.public_url, + width: job.width, + height: job.height, + seed: variant.seed, + steps: job.steps, + style: job.style, + profile: job.profile, + prompt_mode: job.prompt_mode, + duration_ms: variant.duration_ms, + favorite: variant.favorite, + parameters: { + width: job.width, height: job.height, steps: job.steps, style: job.style, profile: job.profile, + prompt_mode: job.prompt_mode, variants: job.variants_count, seed_lock: job.seed_lock, + }, + }; +} + +function renderFluxVariantResults(job) { + const container = document.getElementById('flux-studio-variant-results'); + if (!container) return; + const completed = (job?.variants || []).filter(variant => variant.image_path); + container.classList.toggle('hidden', !completed.length); + if (!completed.length) { container.innerHTML = ''; return; } + container.innerHTML = completed.map(variant => { + const item = fluxVariantToImage(job, variant); + const active = Number(state.fluxStudioCurrent?.id) === Number(item.id); + return ``; + }).join(''); + container.querySelectorAll('[data-flux-variant-image]').forEach(button => button.addEventListener('click', () => { + const variant = completed.find(entry => String(entry.image_id) === button.dataset.fluxVariantImage); + const item = fluxVariantToImage(job, variant); + if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); } + })); +} + +function renderFluxStudioPreview(item) { + state.fluxStudioCurrent = item || null; + const image = document.getElementById('flux-studio-preview-image'); + const empty = document.getElementById('flux-studio-preview-empty'); + const actions = document.getElementById('flux-studio-preview-actions'); + const meta = document.getElementById('flux-studio-preview-meta'); + const download = document.getElementById('flux-studio-download'); + const favorite = document.getElementById('flux-studio-favorite'); + if (!item?.image_path) { + image?.classList.add('hidden'); + empty?.classList.remove('hidden'); + actions?.classList.add('hidden'); + if (meta) meta.textContent = 'Noch kein Bild'; + return; + } + if (image) { + image.src = item.public_url || photoUrl(item.image_path); + image.alt = item.prompt ? `FLUX: ${item.prompt}` : 'FLUX-Vorschau'; + image.classList.remove('hidden'); + } + empty?.classList.add('hidden'); + actions?.classList.remove('hidden'); + if (meta) meta.textContent = `${item.width || '–'} × ${item.height || '–'} · Seed ${item.seed ?? '–'} · ${item.steps || '–'} Schritte${item.duration_ms ? ` · ${formatFluxDuration(item.duration_ms)}` : ''}`; + if (download) { + download.href = item.public_url || photoUrl(item.image_path); + download.download = `vendoo-flux-${item.id || Date.now()}.${String(item.image_path).split('.').pop() || 'png'}`; + } + if (favorite) favorite.textContent = item.favorite ? '★ Favorit' : '☆ Favorit'; +} + +function renderFluxStudioHistory() { + const container = document.getElementById('flux-studio-history'); + if (!container) return; + if (!state.fluxStudioHistory.length) { + container.innerHTML = '
                                                                      Noch keine passenden FLUX-Bilder gespeichert.
                                                                      '; + return; + } + container.innerHTML = state.fluxStudioHistory.map(item => `
                                                                      + +
                                                                      ${escapeHtml(item.prompt || 'FLUX-Bild')}${item.width} × ${item.height} · Seed ${item.seed ?? '–'} · ${formatFluxDate(item.created_at)}
                                                                      +
                                                                      +
                                                                      `).join(''); + container.querySelectorAll('[data-flux-select]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSelect); + if (item) { renderFluxStudioPreview(item); renderFluxStudioHistory(); } + })); + container.querySelectorAll('[data-flux-favorite]').forEach(button => button.addEventListener('click', async () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxFavorite); + if (item) await toggleFluxFavorite(item); + })); + container.querySelectorAll('[data-flux-similar]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSimilar); + if (item) createSimilarFluxJob(item); + })); + container.querySelectorAll('[data-flux-edit]').forEach(button => button.addEventListener('click', () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxEdit); + if (item) { renderFluxStudioPreview(item); openPhotoEditor(0, 'flux'); } + })); + container.querySelectorAll('[data-flux-delete]').forEach(button => button.addEventListener('click', async () => { + const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxDelete); + if (item) await deleteFluxStudioImage(item); + })); +} + +function renderFluxHistoryPagination() { + const pagination = state.fluxHistoryPagination || {}; + const page = Number(pagination.page || 1); + const pages = Number(pagination.total_pages || 1); + const total = Number(pagination.total || 0); + document.getElementById('flux-history-page-info')?.replaceChildren(document.createTextNode(`Seite ${page} von ${pages} · ${total} Bilder`)); + const prev = document.getElementById('flux-history-prev'); + const next = document.getElementById('flux-history-next'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; +} + +async function loadFluxStudioHistory({ silent = false } = {}) { + const container = document.getElementById('flux-studio-history'); + if (!silent && container && !state.fluxStudioHistory.length) container.innerHTML = '
                                                                      FLUX-Bibliothek wird geladen …
                                                                      '; + try { + const favorites = document.getElementById('flux-studio-favorites-only')?.checked ? '1' : '0'; + const result = await api('GET', `/api/flux-studio/images?page=${state.fluxHistoryPage}&page_size=${state.fluxHistoryPageSize}&favorites=${favorites}`); + state.fluxStudioHistory = result.items || []; + state.fluxHistoryPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxStudioHistory.length }; + state.fluxHistoryPage = Number(state.fluxHistoryPagination.page || 1); + if (state.fluxStudioCurrent) { + const refreshed = state.fluxStudioHistory.find(item => item.id === state.fluxStudioCurrent.id); + if (refreshed) renderFluxStudioPreview(refreshed); + else renderFluxStudioPreview(null); + } + renderFluxStudioHistory(); + renderFluxHistoryPagination(); + } catch (error) { + if (container) container.innerHTML = `
                                                                      Bibliothek konnte nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +async function toggleFluxFavorite(item = state.fluxStudioCurrent) { + if (!item?.id) return; + try { + const updated = await api('PATCH', `/api/flux-studio/images/${item.id}/favorite`, { favorite: !item.favorite }); + state.fluxStudioHistory = state.fluxStudioHistory.map(entry => entry.id === updated.id ? updated : entry); + if (state.fluxStudioCurrent?.id === updated.id) renderFluxStudioPreview(updated); + await loadFluxStudioHistory({ silent: true }); + toast(updated.favorite ? 'Als Favorit gespeichert.' : 'Favorit entfernt.'); + } catch (error) { toast(`Favorit konnte nicht geändert werden: ${error.message}`); } +} + +async function deleteFluxStudioImage(item = state.fluxStudioCurrent) { + if (!item?.id) return; + if (!window.confirm('Dieses FLUX-Bild wirklich dauerhaft löschen?')) return; + try { + await api('DELETE', `/api/flux-studio/images/${item.id}`); + if (state.fluxStudioCurrent?.id === item.id) renderFluxStudioPreview(null); + await loadFluxStudioHistory({ silent: true }); + toast('FLUX-Bild gelöscht.'); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function getFluxStudioPayload() { + const { width, height } = parseFluxFormat(); + return { + prompt: document.getElementById('flux-studio-prompt')?.value.trim() || '', + width, + height, + seed: Number(document.getElementById('flux-studio-seed')?.value || 42), + steps: Number(document.getElementById('flux-studio-steps')?.value || 4), + style: document.getElementById('flux-studio-style')?.value || 'none', + profile: document.getElementById('flux-studio-profile')?.value || 'balanced', + prompt_mode: document.getElementById('flux-studio-prompt-mode')?.value || 'precise', + assistant: true, + variants: Number(document.getElementById('flux-studio-variants')?.value || 1), + seed_lock: !!document.getElementById('flux-studio-seed-lock')?.checked, + }; +} + +function applyFluxJobToForm(jobOrImage) { + const item = jobOrImage || {}; + const params = item.parameters || {}; + const prompt = document.getElementById('flux-studio-prompt'); + if (prompt && item.prompt) { prompt.value = item.prompt; prompt.dispatchEvent(new Event('input')); } + const format = `${item.width || params.width || 768}x${item.height || params.height || 768}`; + const formatSelect = document.getElementById('flux-studio-format'); + if (formatSelect && [...formatSelect.options].some(option => option.value === format)) formatSelect.value = format; + const steps = document.getElementById('flux-studio-steps'); + if (steps && (item.steps || params.steps)) steps.value = String(item.steps || params.steps); + const style = document.getElementById('flux-studio-style'); if (style) style.value = item.style || params.style || 'none'; + const mode = document.getElementById('flux-studio-prompt-mode'); if (mode) mode.value = item.prompt_mode || params.prompt_mode || 'precise'; + const seed = document.getElementById('flux-studio-seed'); if (seed && Number.isFinite(Number(item.seed ?? item.seed_base))) seed.value = String(item.seed ?? item.seed_base); + const lock = document.getElementById('flux-studio-seed-lock'); if (lock) lock.checked = !!(item.seed_lock ?? params.seed_lock); + setFluxVariantCount(item.variants_count || params.variants || 1); + applyFluxProfile(item.profile || params.profile || 'custom'); +} + +async function watchFluxJob(jobId) { + if (!jobId || state.fluxStudioWatchers.has(jobId)) return; + state.fluxStudioWatchers.set(jobId, true); + try { + const deadline = Date.now() + 12 * 60 * 1000; + while (Date.now() < deadline) { + const job = await api('GET', `/api/flux-prompt/jobs/${encodeURIComponent(jobId)}`); + const activeVariant = (job.variants || []).find(variant => variant.status === 'running') || (job.variants || []).find(variant => variant.status === 'queued'); + const label = activeVariant?.message || job.message || fluxStatusLabel(job.status); + const stateName = job.status === 'failed' ? 'error' : job.status === 'cancelled' ? 'cancelled' : FLUX_TERMINAL_JOB_STATUSES.has(job.status) ? 'done' : 'active'; + setFluxStudioProgress(label, true, stateName); + setFluxStudioStatus(label, stateName === 'error' ? 'error' : stateName === 'done' ? 'success' : 'working'); + const images = (job.variants || []).filter(variant => variant.image_path); + if (images.length) { + const selected = fluxVariantToImage(job, images[0]); + if (selected) renderFluxStudioPreview(selected); + renderFluxVariantResults(job); + } + await loadFluxJobs({ silent: true }); + if (FLUX_TERMINAL_JOB_STATUSES.has(job.status)) { + await loadFluxStudioHistory(); + await refreshFluxRuntimeInfo({ silent: true }); + if (job.status === 'failed') toast(`FLUX-Auftrag fehlgeschlagen: ${job.error || 'Unbekannter Fehler'}`); + else if (job.status === 'cancelled') toast('FLUX-Auftrag wurde abgebrochen.'); + else toast(job.status === 'completed_with_errors' ? 'FLUX-Auftrag teilweise abgeschlossen.' : 'FLUX-Auftrag abgeschlossen.'); + return job; + } + await new Promise(resolve => setTimeout(resolve, 1400)); + } + setFluxStudioStatus('Der Auftrag läuft weiter im Job-Center.', 'working'); + } catch (error) { + setFluxStudioStatus(error.message, 'error'); + } finally { + state.fluxStudioWatchers.delete(jobId); + } +} + +async function runFluxStudioGeneration(payloadOverride = null) { + if (state.fluxStudioBusy) return; + const payload = payloadOverride || getFluxStudioPayload(); + if (String(payload.prompt || '').trim().length < 3) return toast('Bitte einen aussagekräftigen Prompt eingeben.'); + const button = document.getElementById('flux-studio-generate'); + state.fluxStudioBusy = true; + if (button) button.disabled = true; + setFluxStudioProgress('Auftrag wird dauerhaft gespeichert …', true, 'active'); + setFluxStudioStatus('FLUX-Auftrag wird erstellt …', 'working'); + try { + const queued = await api('POST', '/api/flux-prompt/jobs', payload); + setFluxStudioStatus(`${queued.variants_count} Variante${queued.variants_count === 1 ? '' : 'n'} wurde${queued.variants_count === 1 ? '' : 'n'} eingereiht.`, 'success'); + await loadFluxJobs({ silent: true }); + watchFluxJob(queued.id); + return queued; + } catch (error) { + setFluxStudioStatus(error.message, 'error'); + setFluxStudioProgress('Auftrag konnte nicht erstellt werden.', true, 'error'); + toast(`FLUX-Fehler: ${error.message}`); + return null; + } finally { + state.fluxStudioBusy = false; + if (button) button.disabled = false; + refreshFluxStudioEngineStatus(); + } +} + +async function createSimilarFluxJob(item = state.fluxStudioCurrent) { + if (!item?.prompt) return; + applyFluxJobToForm(item); + const seed = Math.floor(Math.random() * 2147483647); + const seedInput = document.getElementById('flux-studio-seed'); if (seedInput) seedInput.value = String(seed); + toast('Prompt und Parameter wurden übernommen; der neue Basis-Seed erzeugt ähnliche Varianten.'); + await runFluxStudioGeneration(); +} + +function renderFluxJobSummary() { + const container = document.getElementById('flux-job-summary'); + if (!container) return; + const summary = state.fluxJobSummary || {}; + const jobs = summary.jobs || {}; + const variants = summary.variants || {}; + document.getElementById('flux-job-archive-count')?.replaceChildren(document.createTextNode(String(summary.archived_total || 0))); + container.innerHTML = state.fluxJobArchiveMode + ? `
                                                                      Archivierte Aufträge${summary.archived_total || 0}
                                                                      +
                                                                      Auf dieser Seite${state.fluxJobs.length}
                                                                      +
                                                                      Seiten${state.fluxJobPagination.total_pages || 1}
                                                                      +
                                                                      Bilder bleiben erhaltenJa
                                                                      ` + : `
                                                                      Aktive Worker${summary.active_workers || 0} / ${summary.max_parallel || 1}
                                                                      +
                                                                      Wartende Varianten${variants.queued || 0}
                                                                      +
                                                                      Laufende Varianten${variants.running || 0}
                                                                      +
                                                                      Fertige Aufträge${(jobs.completed || 0) + (jobs.completed_with_errors || 0)}
                                                                      `; +} + +function updateFluxJobToolbar() { + const count = state.fluxJobSelected.size; + const archiveMode = state.fluxJobArchiveMode; + document.getElementById('flux-job-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`)); + const selectPage = document.getElementById('flux-job-select-page'); + if (selectPage) { + const selectedOnPage = state.fluxJobs.filter(job => state.fluxJobSelected.has(job.id)).length; + selectPage.checked = state.fluxJobs.length > 0 && selectedOnPage === state.fluxJobs.length; + selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.fluxJobs.length; + } + const archive = document.getElementById('flux-job-archive-selected'); + const restore = document.getElementById('flux-job-restore-selected'); + const remove = document.getElementById('flux-job-delete-selected'); + const removeAll = document.getElementById('flux-job-delete-all-archive'); + archive?.classList.toggle('hidden', archiveMode); + restore?.classList.toggle('hidden', !archiveMode); + remove?.classList.toggle('hidden', !archiveMode); + removeAll?.classList.toggle('hidden', !archiveMode); + if (archive) archive.disabled = count === 0; + if (restore) restore.disabled = count === 0; + if (remove) remove.disabled = count === 0; + document.getElementById('flux-job-clear-queue')?.classList.toggle('hidden', archiveMode); + document.getElementById('flux-job-filter')?.classList.toggle('hidden', archiveMode); + document.getElementById('flux-job-view-active')?.classList.toggle('active', !archiveMode); + document.getElementById('flux-job-view-archive')?.classList.toggle('active', archiveMode); +} + +function renderFluxJobPagination() { + const pagination = state.fluxJobPagination || {}; + const page = Number(pagination.page || 1); + const pages = Number(pagination.total_pages || 1); + const total = Number(pagination.total || 0); + const info = document.getElementById('flux-job-page-info'); + if (info) info.textContent = `Seite ${page} von ${pages} · ${total} Auftrag${total === 1 ? '' : 'e'}`; + const prev = document.getElementById('flux-job-prev'); + const next = document.getElementById('flux-job-next'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; +} + +async function archiveFluxJobs(ids) { + if (!ids.length) return; + const result = await api('POST', '/api/flux-prompt/jobs/archive', { ids }); + state.fluxJobSelected.clear(); + toast(`${result.archived || 0} Auftrag/Aufträge archiviert${result.skipped ? ` · ${result.skipped} aktiv oder nicht archiviert` : ''}.`); + await loadFluxJobs(); +} + +async function restoreFluxJobs(ids) { + if (!ids.length) return; + const result = await api('POST', '/api/flux-prompt/jobs/restore', { ids }); + state.fluxJobSelected.clear(); + toast(`${result.restored || 0} Auftrag/Aufträge wiederhergestellt.`); + await loadFluxJobs(); +} + +async function deleteArchivedFluxJobs(ids = [], all = false) { + const message = all + ? 'Das komplette FLUX-Auftragsarchiv endgültig löschen? Die erzeugten Bilder bleiben in der Bildergalerie erhalten.' + : `${ids.length} archivierte Auftrag/Aufträge endgültig löschen? Die erzeugten Bilder bleiben erhalten.`; + if (!window.confirm(message)) return; + const result = await api('DELETE', `/api/flux-prompt/jobs/archive${all ? '?all=1' : ''}`, { ids }); + state.fluxJobSelected.clear(); + toast(`${result.deleted || 0} archivierte Auftrag/Aufträge gelöscht.`); + await loadFluxJobs(); +} + +function renderFluxJobList() { + const container = document.getElementById('flux-job-list'); + if (!container) return; + const jobs = state.fluxJobs; + if (!jobs.length) { + container.innerHTML = `
                                                                      ${state.fluxJobArchiveMode ? 'Das Auftragsarchiv ist leer.' : 'Keine Aufträge für diesen Filter.'}
                                                                      `; + updateFluxJobToolbar(); + return; + } + container.innerHTML = jobs.map(job => { + const variants = job.variants || []; + const expanded = state.fluxJobExpanded.has(job.id); + const selected = state.fluxJobSelected.has(job.id); + const thumbs = variants.map(variant => variant.image_path + ? `` + : `
                                                                      ${variant.variant_index}${escapeHtml(fluxStatusLabel(variant.status))} · Seed ${variant.seed}
                                                                      `).join(''); + const ledger = variants.map(variant => `
                                                                      V${variant.variant_index}Seed ${variant.seed}${escapeHtml(variant.phase || variant.status)}${variant.prompt_id ? `${escapeHtml(String(variant.prompt_id).slice(0, 12))}${String(variant.prompt_id).length > 12 ? '…' : ''}` : 'Noch keine Prompt-ID'}
                                                                      `).join(''); + const canCancel = FLUX_ACTIVE_JOB_STATUSES.has(job.status); + const canRetry = ['failed', 'cancelled', 'completed_with_errors'].includes(job.status) && !state.fluxJobArchiveMode; + const canArchive = !state.fluxJobArchiveMode && !canCancel; + return `
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      ${job.width} × ${job.height}${job.steps} Schritte${escapeHtml(job.style)}${job.variants_count} Variante${job.variants_count === 1 ? '' : 'n'}Seed ${job.seed_base}${job.seed_lock ? ' · Lock' : ''}

                                                                      ${escapeHtml(job.message || '')}${job.error ? `
                                                                      ${escapeHtml(job.error)}` : ''}

                                                                      +
                                                                      ${thumbs}
                                                                      ${ledger}
                                                                      +
                                                                      + + ${canCancel ? `` : ''} + ${canRetry ? `` : ''} + ${canArchive ? `` : ''} + ${state.fluxJobArchiveMode ? `` : ''} +
                                                                      +
                                                                      +
                                                                      `; + }).join(''); + container.querySelectorAll('[data-flux-job-select]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.fluxJobSelected.add(input.dataset.fluxJobSelect); else state.fluxJobSelected.delete(input.dataset.fluxJobSelect); + input.closest('.flux-job-card')?.classList.toggle('is-selected', input.checked); + updateFluxJobToolbar(); + })); + container.querySelectorAll('[data-flux-job-toggle]').forEach(button => button.addEventListener('click', () => { + const jobId = button.dataset.fluxJobToggle; + state.fluxJobManuallyToggled.add(jobId); + if (state.fluxJobExpanded.has(jobId)) state.fluxJobExpanded.delete(jobId); else state.fluxJobExpanded.add(jobId); + renderFluxJobList(); + })); + container.querySelectorAll('[data-flux-job-load]').forEach(button => button.addEventListener('click', () => { + const job = state.fluxJobs.find(entry => entry.id === button.dataset.fluxJobLoad); + if (job) { applyFluxJobToForm(job); setFluxStudioStatus('Prompt und Parameter wurden aus dem Verlauf geladen.', 'success'); } + })); + container.querySelectorAll('[data-flux-job-cancel]').forEach(button => button.addEventListener('click', async () => { + try { await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobCancel}/cancel`, {}); toast('Abbruch wurde angefordert.'); await loadFluxJobs(); } catch (error) { toast(`Abbruch fehlgeschlagen: ${error.message}`); } + })); + container.querySelectorAll('[data-flux-job-retry]').forEach(button => button.addEventListener('click', async () => { + try { const job = await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobRetry}/retry`, {}); state.fluxJobExpanded.add(job.id); toast('Auftrag wurde erneut eingereiht.'); await loadFluxJobs(); watchFluxJob(job.id); } catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); } + })); + container.querySelectorAll('[data-flux-job-archive]').forEach(button => button.addEventListener('click', () => archiveFluxJobs([button.dataset.fluxJobArchive]))); + container.querySelectorAll('[data-flux-job-restore]').forEach(button => button.addEventListener('click', () => restoreFluxJobs([button.dataset.fluxJobRestore]))); + container.querySelectorAll('[data-flux-job-delete]').forEach(button => button.addEventListener('click', () => deleteArchivedFluxJobs([button.dataset.fluxJobDelete]))); + container.querySelectorAll('[data-flux-job-image]').forEach(button => button.addEventListener('click', () => { + const [jobId, variantIndex] = button.dataset.fluxJobImage.split(':'); + const job = state.fluxJobs.find(entry => entry.id === jobId); + const variant = job?.variants?.find(entry => String(entry.variant_index) === variantIndex); + const item = fluxVariantToImage(job, variant); + if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); } + })); + updateFluxJobToolbar(); +} + +async function loadFluxJobs({ silent = false } = {}) { + const container = document.getElementById('flux-job-list'); + if (!silent && container && !state.fluxJobs.length) container.innerHTML = '
                                                                      Job-Center wird geladen …
                                                                      '; + try { + const filter = state.fluxJobArchiveMode ? '' : (document.getElementById('flux-job-filter')?.value || ''); + const params = new URLSearchParams({ page: String(state.fluxJobPage), page_size: String(state.fluxJobPageSize), archived: state.fluxJobArchiveMode ? '1' : '0' }); + if (filter) params.set('status', filter); + const result = await api('GET', `/api/flux-prompt/jobs?${params}`); + state.fluxJobs = result.jobs || []; + state.fluxJobSummary = result.summary || null; + state.fluxJobPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxJobs.length }; + state.fluxJobPage = Number(state.fluxJobPagination.page || 1); + state.fluxJobSelected = new Set([...state.fluxJobSelected].filter(id => state.fluxJobs.some(job => job.id === id))); + for (const job of state.fluxJobs) if (FLUX_ACTIVE_JOB_STATUSES.has(job.status) && !state.fluxJobManuallyToggled.has(job.id)) state.fluxJobExpanded.add(job.id); + renderFluxJobSummary(); renderFluxJobList(); renderFluxJobPagination(); updateFluxJobToolbar(); + if (!state.fluxJobArchiveMode) for (const job of state.fluxJobs.filter(entry => FLUX_ACTIVE_JOB_STATUSES.has(entry.status))) watchFluxJob(job.id); + } catch (error) { + if (!silent && container) container.innerHTML = `
                                                                      Job-Center konnte nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +function bytesToGiB(value) { + const number = Number(value || 0); + return number > 0 ? `${(number / 1024 / 1024 / 1024).toFixed(1)} GB` : '–'; +} + +function renderFluxRuntimeInfo() { + const container = document.getElementById('flux-runtime-summary'); + if (!container) return; + const runtime = state.fluxRuntime; + if (!runtime) { container.innerHTML = '
                                                                      Keine Systemdaten verfügbar.
                                                                      '; return; } + const stats = runtime.system_stats || {}; + const system = stats.system || stats; + const devices = Array.isArray(stats.devices) ? stats.devices : Array.isArray(system.devices) ? system.devices : []; + const gpu = devices[0] || {}; + const queue = runtime.queue_summary || {}; + const historyCount = runtime.history ? Object.keys(runtime.history).length : 0; + container.innerHTML = ` +
                                                                      +
                                                                      ComfyUI${runtime.reachable ? 'Erreichbar' : 'Nicht erreichbar'}${escapeHtml(runtime.url || '')}
                                                                      +
                                                                      ComfyUI-Queue${queue.running || 0} aktiv · ${queue.pending || 0} wartend${historyCount} Historieneinträge geladen
                                                                      +
                                                                      GPU${escapeHtml(gpu.name || gpu.type || 'Nicht gemeldet')}${gpu.vram_total ? `${bytesToGiB(gpu.vram_free)} frei von ${bytesToGiB(gpu.vram_total)}` : 'VRAM-Daten nicht gemeldet'}
                                                                      +
                                                                      System${escapeHtml(system.os || system.system || system.python_version || 'ComfyUI-System')}${escapeHtml(system.comfyui_version || system.version || '')}
                                                                      +
                                                                      `; +} + +async function refreshFluxRuntimeInfo({ silent = false } = {}) { + const container = document.getElementById('flux-runtime-summary'); + if (!silent && container) container.innerHTML = '
                                                                      ComfyUI-Warteschlange, Historie und GPU-Daten werden geladen …
                                                                      '; + try { + state.fluxRuntime = await api('GET', '/api/flux-prompt/runtime'); + renderFluxRuntimeInfo(); + } catch (error) { + state.fluxRuntime = null; + if (container) container.innerHTML = `
                                                                      Systemdaten nicht verfügbar: ${escapeHtml(error.message)}
                                                                      `; + } +} + +async function useFluxImageInGenerator() { + const item = state.fluxStudioCurrent; + if (!item?.id) return; + try { + const result = await api('POST', `/api/flux-studio/images/${item.id}/copy-to-generator`, {}); + if (result.filename && !state.photos.includes(result.filename)) state.photos.push(result.filename); + renderPhotoPreview(); + switchToTab('generator'); + toast('Eine unabhängige Kopie wurde als Artikelfoto übernommen.'); + } catch (error) { toast(`Übernahme fehlgeschlagen: ${error.message}`); } +} + + +function aiProviderDisplayName(provider = state.aiProvider) { + return state.providers.find(entry => entry.id === provider)?.name || ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || 'AI'; +} + +function openFluxPromptLab() { + const prompt = document.getElementById('flux-studio-prompt'); + if (!prompt?.value.trim()) return toast('Bitte zuerst einen Prompt eingeben.'); + const modal = document.getElementById('flux-prompt-lab'); + document.getElementById('flux-prompt-lab-original').value = prompt.value.trim(); + document.getElementById('flux-prompt-lab-result').value = ''; + document.getElementById('flux-prompt-lab-notes').textContent = ''; + document.getElementById('flux-prompt-lab-status').textContent = 'Bereit für lokale Strukturierung oder AI-Veredelung.'; + document.getElementById('flux-prompt-lab-provider').textContent = `${aiProviderDisplayName()}${state.aiModel ? ` · ${state.aiModel}` : ' · Auto'}`; + modal?.classList.remove('hidden'); + modal?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + setTimeout(() => document.getElementById('flux-prompt-lab-original')?.focus(), 40); +} + +function closeFluxPromptLab() { + const modal = document.getElementById('flux-prompt-lab'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + if (document.getElementById('smart-copy-modal')?.classList.contains('hidden')) document.body.classList.remove('modal-open'); +} + +function setupFluxPromptLab() { + document.querySelectorAll('[data-flux-prompt-lab-close]').forEach(button => button.addEventListener('click', closeFluxPromptLab)); + document.getElementById('flux-prompt-lab-local')?.addEventListener('click', () => { + const original = document.getElementById('flux-prompt-lab-original')?.value || ''; + const optimized = optimizeFluxPromptLocally(original); + if (!optimized) return toast('Bitte zuerst einen Prompt eingeben.'); + document.getElementById('flux-prompt-lab-result').value = optimized; + document.getElementById('flux-prompt-lab-status').textContent = 'Lokal strukturiert · keine API-Anfrage.'; + document.getElementById('flux-prompt-lab-notes').textContent = 'Motiv, Licht, Komposition und Qualitätsmerkmale wurden in eine klarere Reihenfolge gebracht.'; + }); + document.getElementById('flux-prompt-lab-ai')?.addEventListener('click', async event => { + const original = document.getElementById('flux-prompt-lab-original')?.value.trim() || ''; + if (!original) return toast('Bitte zuerst einen Prompt eingeben.'); + const button = event.currentTarget; + const status = document.getElementById('flux-prompt-lab-status'); + button.disabled = true; + button.textContent = `${aiProviderDisplayName()} arbeitet …`; + status.textContent = `Prompt wird mit ${aiProviderDisplayName()} veredelt …`; + try { + const payload = { + prompt: original, + provider: state.aiProvider, + model: state.aiModel || undefined, + goal: document.getElementById('flux-prompt-lab-goal')?.value || 'product', + preserve_subject: document.getElementById('flux-prompt-lab-preserve')?.checked !== false, + avoid_text: document.getElementById('flux-prompt-lab-avoid-text')?.checked !== false, + }; + const result = await api('POST', '/api/flux-prompt/improve', payload); + document.getElementById('flux-prompt-lab-result').value = result.improved_prompt || ''; + document.getElementById('flux-prompt-lab-notes').textContent = result.notes || 'Prompt wurde für FLUX veredelt.'; + status.textContent = `Fertig · ${aiProviderDisplayName(result.provider)}${result.model ? ` · ${result.model}` : ''}`; + } catch (error) { + status.textContent = `AI-Veredelung fehlgeschlagen: ${error.message}`; + toast(`Prompt Lab: ${error.message}`); + } finally { + button.disabled = false; + button.textContent = 'Mit AI veredeln'; + } + }); + document.getElementById('flux-prompt-lab-apply')?.addEventListener('click', () => { + const result = document.getElementById('flux-prompt-lab-result')?.value.trim(); + if (!result) return toast('Erzeuge zuerst einen optimierten Prompt.'); + const prompt = document.getElementById('flux-studio-prompt'); + prompt.value = result.slice(0, 2000); + prompt.dispatchEvent(new Event('input', { bubbles: true })); + prompt.focus(); + closeFluxPromptLab(); + toast('Optimierter Prompt wurde übernommen.'); + }); +} + +function setupFluxStudio() { + const prompt = document.getElementById('flux-studio-prompt'); + if (!prompt) return; + const updateCount = () => { const count = document.getElementById('flux-studio-prompt-count'); if (count) count.textContent = String(prompt.value.length); }; + prompt.addEventListener('input', () => { updateCount(); refreshFluxPromptQuality(); }); + updateCount(); + refreshFluxPromptQuality(); + document.getElementById('flux-studio-optimize-prompt')?.addEventListener('click', openFluxPromptLab); + setupFluxPromptLab(); + document.querySelectorAll('[data-flux-profile]').forEach(button => button.addEventListener('click', () => applyFluxProfile(button.dataset.fluxProfile))); + document.querySelectorAll('[data-flux-variants]').forEach(button => button.addEventListener('click', () => setFluxVariantCount(button.dataset.fluxVariants))); + document.getElementById('flux-studio-format')?.addEventListener('change', () => applyFluxProfile('custom')); + document.getElementById('flux-studio-steps')?.addEventListener('change', () => applyFluxProfile('custom')); + let savedFluxProfile = 'balanced'; try { savedFluxProfile = localStorage.getItem('vendoo-flux-profile') || savedFluxProfile; } catch {} + let savedFluxVariants = 1; try { savedFluxVariants = Number(localStorage.getItem('vendoo-flux-variants') || 1); } catch {} + applyFluxProfile(savedFluxProfile, { persist: false }); + setFluxVariantCount(savedFluxVariants, { persist: false }); + renderFluxStudioPreview(null); + const initialVariantResults = document.getElementById('flux-studio-variant-results'); + if (initialVariantResults) { initialVariantResults.innerHTML = ''; initialVariantResults.classList.add('hidden'); } + document.querySelectorAll('[data-flux-template]').forEach(button => button.addEventListener('click', () => { + const template = FLUX_PROMPT_TEMPLATES[button.dataset.fluxTemplate]; + if (!template) return; + prompt.value = prompt.value.trim() ? `${prompt.value.trim()}, ${template}` : template; + updateCount(); refreshFluxPromptQuality(); prompt.focus(); + })); + document.getElementById('flux-studio-random-seed')?.addEventListener('click', () => { + document.getElementById('flux-studio-seed').value = String(Math.floor(Math.random() * 2147483647)); + }); + document.getElementById('flux-studio-generate')?.addEventListener('click', () => runFluxStudioGeneration()); + document.getElementById('flux-studio-reset')?.addEventListener('click', () => { + if (state.fluxStudioBusy && !window.confirm('Ein FLUX-Auftrag läuft noch. Nur den sichtbaren Arbeitsbereich zurücksetzen? Der Auftrag läuft im Job-Center weiter.')) return; + resetFluxStudioWorkspace({ clearSavedDefaults: true, announce: true }); + }); + document.getElementById('flux-studio-refresh-engine')?.addEventListener('click', async () => { await refreshFluxStudioEngineStatus(); await refreshFluxRuntimeInfo(); }); + document.getElementById('flux-runtime-refresh')?.addEventListener('click', () => refreshFluxRuntimeInfo()); + document.getElementById('flux-job-refresh')?.addEventListener('click', () => loadFluxJobs()); + document.getElementById('flux-job-filter')?.addEventListener('change', () => { state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-page-size')?.addEventListener('change', event => { state.fluxJobPageSize = Number(event.target.value) || 10; state.fluxJobPage = 1; loadFluxJobs(); }); + document.getElementById('flux-job-view-active')?.addEventListener('click', () => { state.fluxJobArchiveMode = false; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-view-archive')?.addEventListener('click', () => { state.fluxJobArchiveMode = true; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); }); + document.getElementById('flux-job-prev')?.addEventListener('click', () => { if (state.fluxJobPage > 1) { state.fluxJobPage -= 1; loadFluxJobs(); } }); + document.getElementById('flux-job-next')?.addEventListener('click', () => { if (state.fluxJobPage < Number(state.fluxJobPagination.total_pages || 1)) { state.fluxJobPage += 1; loadFluxJobs(); } }); + document.getElementById('flux-job-select-page')?.addEventListener('change', event => { for (const job of state.fluxJobs) { if (event.target.checked) state.fluxJobSelected.add(job.id); else state.fluxJobSelected.delete(job.id); } renderFluxJobList(); }); + document.getElementById('flux-job-archive-selected')?.addEventListener('click', () => archiveFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-restore-selected')?.addEventListener('click', () => restoreFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-delete-selected')?.addEventListener('click', () => deleteArchivedFluxJobs([...state.fluxJobSelected])); + document.getElementById('flux-job-delete-all-archive')?.addEventListener('click', () => deleteArchivedFluxJobs([], true)); + document.getElementById('flux-job-collapse-all')?.addEventListener('click', () => { + for (const job of state.fluxJobs) state.fluxJobManuallyToggled.add(job.id); + state.fluxJobExpanded.clear(); + renderFluxJobList(); + }); + const jobCenterToggle = document.getElementById('flux-job-center-toggle'); + const jobCenterBody = document.getElementById('flux-job-center-body'); + const applyJobCenterState = () => { + jobCenterBody?.classList.toggle('hidden', state.fluxJobCenterCollapsed); + if (jobCenterToggle) { + jobCenterToggle.setAttribute('aria-expanded', String(!state.fluxJobCenterCollapsed)); + jobCenterToggle.textContent = state.fluxJobCenterCollapsed ? 'Aufträge aufklappen' : 'Aufträge einklappen'; + } + }; + try { state.fluxJobCenterCollapsed = localStorage.getItem('vendoo-flux-job-center-collapsed') === '1'; } catch {} + applyJobCenterState(); + jobCenterToggle?.addEventListener('click', () => { + state.fluxJobCenterCollapsed = !state.fluxJobCenterCollapsed; + try { localStorage.setItem('vendoo-flux-job-center-collapsed', state.fluxJobCenterCollapsed ? '1' : '0'); } catch {} + applyJobCenterState(); + }); + document.getElementById('flux-job-clear-queue')?.addEventListener('click', async () => { + if (!window.confirm('Alle noch wartenden FLUX-Varianten aus der Warteschlange entfernen? Laufende Varianten bleiben unberührt.')) return; + try { const result = await api('DELETE', '/api/flux-prompt/jobs/queue'); toast(`${result.cleared_variants || 0} wartende Variante(n) entfernt.`); await loadFluxJobs(); } + catch (error) { toast(`Warteschlange konnte nicht geleert werden: ${error.message}`); } + }); + document.getElementById('flux-studio-refresh-history')?.addEventListener('click', () => loadFluxStudioHistory()); + document.getElementById('flux-history-page-size')?.addEventListener('change', event => { state.fluxHistoryPageSize = Number(event.target.value) || 5; state.fluxHistoryPage = 1; loadFluxStudioHistory(); }); + document.getElementById('flux-history-prev')?.addEventListener('click', () => { if (state.fluxHistoryPage > 1) { state.fluxHistoryPage -= 1; loadFluxStudioHistory(); } }); + document.getElementById('flux-history-next')?.addEventListener('click', () => { if (state.fluxHistoryPage < Number(state.fluxHistoryPagination.total_pages || 1)) { state.fluxHistoryPage += 1; loadFluxStudioHistory(); } }); + document.getElementById('flux-studio-favorites-only')?.addEventListener('change', () => { state.fluxHistoryPage = 1; loadFluxStudioHistory(); }); + document.getElementById('flux-studio-edit')?.addEventListener('click', () => openPhotoEditor(0, 'flux')); + document.getElementById('flux-studio-similar')?.addEventListener('click', () => createSimilarFluxJob()); + document.getElementById('flux-studio-favorite')?.addEventListener('click', () => toggleFluxFavorite()); + document.getElementById('flux-studio-use-in-generator')?.addEventListener('click', useFluxImageInGenerator); + document.getElementById('flux-studio-delete')?.addEventListener('click', () => deleteFluxStudioImage()); + document.getElementById('open-flux-studio-page-btn')?.addEventListener('click', () => switchToTab('flux-studio')); + document.getElementById('flux-studio-start-engine')?.addEventListener('click', async () => { + const button = document.getElementById('flux-studio-start-engine'); + if (button) button.disabled = true; + try { + const profile = document.getElementById('setting-flux-runtime-profile')?.value || 'auto'; + const result = await api('POST', '/api/local-image/start', { profile }); + toast(result.message || 'FLUX wird gestartet.'); + await waitForFluxEngineReady(); + setFluxStudioStatus('FLUX ist aktiv und bereit.', 'success'); + await refreshFluxRuntimeInfo(); + toast('FLUX ist aktiv und bereit.'); + } catch (error) { + setFluxStudioStatus(`FLUX-Start fehlgeschlagen: ${error.message}`, 'error'); + toast(`FLUX-Start fehlgeschlagen: ${error.message}`); + } finally { + if (button) button.disabled = false; + } + }); + document.getElementById('flux-studio-stop-engine')?.addEventListener('click', async () => { + try { const result = await api('POST', '/api/local-image/stop', {}); toast(result.message || 'FLUX wird gestoppt.'); setTimeout(refreshFluxStudioEngineStatus, 1200); } + catch (error) { toast(`FLUX-Stopp fehlgeschlagen: ${error.message}`); } + }); + refreshFluxStudioEngineStatus(); + loadFluxStudioHistory(); + loadFluxJobs(); + refreshFluxRuntimeInfo(); + const poller = window.setInterval(() => { + const page = document.getElementById('flux-studio'); + if (page?.classList.contains('active')) { + loadFluxJobs({ silent: true }); + refreshFluxRuntimeInfo({ silent: true }); + } + }, 5000); + window.addEventListener('beforeunload', () => window.clearInterval(poller), { once: true }); +} + + +function updateAiModeControls(scope = 'generator') { + const promptButton = document.getElementById(`${scope}-ai-model-prompt-btn`); + if (promptButton) { + promptButton.disabled = false; + promptButton.title = 'Optionale Zusatzanweisung'; + } +} + +async function runGeneratorAiModelPhotos() { + if (!state.photos.length) return toast('Bitte zuerst Produktfotos hochladen'); + const button = document.getElementById('generator-ai-model-btn'); + button.disabled = true; + setAiModelStatus('generator-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const draft = getAiPromptDraft('generator'); + const payload = { ...getGeneratorAiModelPayload(), custom_prompt: draft.customPrompt || '', preserve_logos: draft.preserveLogos ? '1' : '0' }; + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('generator-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelJobProgress('generator-ai-model-status', job); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + state.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('generator', state.aiModelPhotos); + const providerLabel = state.aiModelPhotos[0]?.provider_model || state.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('generator-ai-model-status', `${state.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + } catch (err) { + setAiModelStatus('generator-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + button.disabled = false; + } +} + +async function runDetailAiModelPhotos() { + if (!detailEditorState?.photos?.length) return toast('Bitte zuerst Produktfotos hinzufügen'); + const button = document.getElementById('detail-ai-model-btn'); + if (button) button.disabled = true; + setAiModelStatus('detail-ai-model-status', 'Sicherheitsprüfung läuft …', 'working'); + try { + const draft = getAiPromptDraft('detail'); + const payload = { + listing_id: detailEditorState.listing.id, + photos: [...detailEditorState.photos], + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || detailEditorState.listing.title || '', + category: detailEditorState.categoryPicker?.getValue?.() || detailEditorState.listing.category || '', + brand: document.getElementById('detail-brand')?.value || detailEditorState.listing.brand || '', + color: document.getElementById('detail-color')?.value || detailEditorState.listing.color || '', + size: document.getElementById('detail-size')?.value || detailEditorState.listing.size || '', + condition: document.getElementById('detail-condition')?.value || detailEditorState.listing.condition || '', + seller_notes: document.getElementById('detail-seller-notes')?.value || detailEditorState.listing.seller_notes || '', + mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay', + preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed', + variants: parseInt(document.getElementById('detail-ai-model-variants')?.value || '1', 10), + custom_prompt: draft.customPrompt || '', + preserve_logos: draft.preserveLogos ? '1' : '0', + }; + const precheck = await api('POST', '/api/ai-model-photos/precheck', payload); + if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert'); + setAiModelStatus('detail-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working'); + const queued = await api('POST', '/api/ai-model-photos/jobs', payload); + const finalJob = await pollAiModelJob(queued.job_id, (job) => { + setAiModelJobProgress('detail-ai-model-status', job); + }); + if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.'); + detailEditorState.aiModelPhotos = finalJob.photos || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + const providerLabel = detailEditorState.aiModelPhotos[0]?.provider_model || detailEditorState.aiModelPhotos[0]?.provider || 'AI'; + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success'); + loadDetailAiModelGenerations(detailEditorState.listing.id); + } catch (err) { + setAiModelStatus('detail-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger'); + toast(err.message || 'AI-Model-Fotos fehlgeschlagen'); + } finally { + if (button) button.disabled = false; + } +} + +async function loadDetailAiModelPhotos(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-photos`); + detailEditorState.aiModelPhotos = items || []; + renderAiModelGrid('detail', detailEditorState.aiModelPhotos); + if (detailEditorState.aiModelPhotos.length) { + setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} vorhandene AI-Bilder geladen.`, 'neutral'); + } + } catch {} +} + +function renderDetailAiModelGenerations(generations = []) { + const wrap = document.getElementById('detail-ai-model-history'); + if (!wrap) return; + if (!generations.length) { + wrap.innerHTML = '
                                                                      Noch keine AI-Bildhistorie vorhanden.
                                                                      '; + return; + } + wrap.innerHTML = generations.slice(0, 6).map(gen => ` +
                                                                      +
                                                                      ${escapeHtml((gen.mode || 'model').replace('_', ' '))}${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder
                                                                      +
                                                                      + ${(gen.photos || []).slice(0, 3).map(photo => ``).join('')} +
                                                                      +
                                                                      + `).join(''); + wrap.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => { + const file = button.dataset.aiModelAddOne; + if (!detailEditorState.photos.includes(file)) detailEditorState.photos.push(file); + renderDetailPhotos(); + toast('AI-Bild aus Historie hinzugefügt'); + })); +} + +async function loadDetailAiModelGenerations(listingId) { + try { + const items = await api('GET', `/api/listings/${listingId}/ai-model-generations`); + detailEditorState.aiModelGenerations = items || []; + renderDetailAiModelGenerations(detailEditorState.aiModelGenerations); + } catch {} +} + + + +// --- Tab titles for topbar --- +const TAB_TITLES = { + today: 'Studio Flow', + dashboard: 'Dashboard', + generator: 'Neuer Artikel', + templates: 'Vorlagen', + history: 'Artikel', + 'quality-center': 'Qualitätscenter', + publish: 'Publish', + trash: 'Papierkorb', + inventory: 'Lager', + fees: 'Gebühren', + admin: 'Admin / Benutzer & System', + 'flux-studio': 'FLUX Studio', + 'media-library': 'Bildergalerie', + 'image-factory': 'Bildproduktion', + extensions: 'Extensions', + settings: 'Einstellungen', +}; + +const TAB_KICKERS = { + today: 'Arbeitsplatz', + dashboard: 'Analytics', + generator: 'Generator', + templates: 'Inhalte', + history: 'Übersicht', + 'quality-center': 'Artikelprüfung', + publish: 'Control Center', + trash: 'Wiederherstellung', + inventory: 'Bestand', + fees: 'Kalkulation', + admin: 'Verwaltung', + 'flux-studio': 'Bildgenerator', + 'media-library': 'Medienverwaltung', + 'image-factory': 'Batch Image Factory', + extensions: 'Browser-Integration', + settings: 'Konfiguration', +}; + +function setMobileSidebarOpen(open) { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const toggle = document.getElementById('sidebar-toggle'); + if (!sidebar || !overlay) return; + sidebar.classList.toggle('open', !!open); + overlay.classList.toggle('show', !!open); + document.body.classList.toggle('mobile-nav-open', !!open); + toggle?.setAttribute('aria-expanded', open ? 'true' : 'false'); + sidebar.setAttribute('aria-hidden', (!open && window.matchMedia('(max-width: 768px)').matches) ? 'true' : 'false'); + if (open) { + window.setTimeout(() => sidebar.querySelector('.nav-item.active, .nav-item')?.focus(), 60); + } else { + toggle?.focus({ preventScroll: true }); + } +} + +function closeMobileSidebar({ restoreFocus = false } = {}) { + const toggle = document.getElementById('sidebar-toggle'); + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + sidebar?.classList.remove('open'); + overlay?.classList.remove('show'); + document.body.classList.remove('mobile-nav-open'); + toggle?.setAttribute('aria-expanded', 'false'); + if (sidebar && window.matchMedia('(max-width: 768px)').matches) sidebar.setAttribute('aria-hidden', 'true'); + if (restoreFocus) toggle?.focus({ preventScroll: true }); +} + +function switchToTab(tabId, adminTab) { + if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true }); + document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + const section = document.getElementById(tabId); + if (section) section.classList.add('active'); + window.requestAnimationFrame(applyResponsiveContract); + + // Highlight the correct sidebar item + const selector = adminTab + ? `.nav-item[data-tab="${tabId}"][data-admin-tab="${adminTab}"]` + : `.nav-item[data-tab="${tabId}"]:not([data-admin-tab])`; + const activeBtn = document.querySelector(selector) + || document.querySelector(`.nav-item[data-tab="${tabId}"]`); + if (activeBtn) activeBtn.classList.add('active'); + + // Update topbar title + const titleEl = document.getElementById('topbar-title'); + if (titleEl) titleEl.textContent = TAB_TITLES[tabId] || tabId; + const kickerEl = document.getElementById('topbar-kicker'); + if (kickerEl) kickerEl.textContent = TAB_KICKERS[tabId] || ''; + + // Switch admin sub-tab if specified + if (tabId === 'admin' && adminTab) { + switchAdminTab(adminTab); + if (adminTab === 'users') loadAdminUsers(); + if (adminTab === 'roles') loadRoleMatrix(); + if (adminTab === 'sessions') loadAdminSessions(); + if (adminTab === 'smtp') loadSmtpSettings(); + if (adminTab === 'system') { loadSystemInfo(); loadOperationsCenter(); } + if (adminTab === 'security') loadSecurityCenter(); + if (adminTab === 'audit') loadAdminAudit(); + } + + // Load data for the tab + if (tabId === 'today') loadToday(); + if (tabId === 'dashboard') loadAnalyticsDashboard(); + if (tabId === 'history') { setHistoryDetailMode(false); loadHistory(); } + if (tabId === 'quality-center') loadQualityCenter(); + if (tabId === 'publish') loadPublishList(); + if (tabId === 'trash') loadTrash(); + if (tabId === 'templates') renderTemplateList(); + if (tabId === 'inventory') loadInventory(); + if (tabId === 'fees') updateFeeCalculator(); + if (tabId === 'flux-studio') { refreshFluxStudioEngineStatus(); loadFluxStudioHistory(); loadFluxJobs({ silent: true }); } + if (tabId === 'media-library') loadMediaLibrary(); + if (tabId === 'image-factory') loadImageFactory(); + if (tabId === 'extensions') loadExtensions(); + + // Close mobile sidebar + closeMobileSidebar(); +} + +// --- Tabs --- +function setupTabs() { + document.querySelectorAll('.nav-item').forEach(btn => { + btn.addEventListener('click', () => { + switchToTab(btn.dataset.tab, btn.dataset.adminTab); + }); + }); + + // Mobile sidebar toggle + document.getElementById('sidebar-toggle')?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const open = !document.getElementById('sidebar')?.classList.contains('open'); + setMobileSidebarOpen(open); + }); + document.getElementById('sidebar-close')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.getElementById('sidebar-overlay')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true })); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && document.getElementById('sidebar')?.classList.contains('open')) { + closeMobileSidebar({ restoreFocus: true }); + } + }); + window.addEventListener('resize', debounce(() => { + const mobile = window.matchMedia('(max-width: 768px)').matches; + const sidebar = document.getElementById('sidebar'); + if (!mobile) { + closeMobileSidebar(); + sidebar?.setAttribute('aria-hidden', 'false'); + } else if (!sidebar?.classList.contains('open')) { + sidebar?.setAttribute('aria-hidden', 'true'); + } + }, 120)); + if (window.matchMedia('(max-width: 768px)').matches) document.getElementById('sidebar')?.setAttribute('aria-hidden', 'true'); + + // Topbar "Neuer Artikel" button + document.getElementById('topbar-new-btn')?.addEventListener('click', () => { + switchToTab('generator'); + }); + + // Topbar search button — focus search in history + document.getElementById('topbar-search-btn')?.addEventListener('click', () => { + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + }); + + document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + switchToTab('history'); + setTimeout(() => document.getElementById('search-input')?.focus(), 100); + } + }); + + document.addEventListener('click', event => { + const target = event.target.closest('[data-tab-target]'); + if (target) switchToTab(target.dataset.tabTarget); + }); +} + +// --- Platforms --- +function renderPlatformButtons() { + const c = document.getElementById('platform-buttons'); + c.innerHTML = ''; + for (const p of state.platforms) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.platform ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.platform = p.id; + updateGeneratorReadiness(); + refreshGeneratorPlatformPreview(); + if (!document.getElementById('meta-section').classList.contains('hidden') && metaCategoryPicker) { + const currentVal = metaCategoryPicker.getValue(); + metaCategoryPicker = createCategoryPicker('meta-category-picker', p.id, null); + if (currentVal) { + const name = currentVal.split(' > ').pop(); + api('GET', `/api/categories/${p.id}/match?q=${encodeURIComponent(name)}`).then(matches => { + if (matches.length && matches[0].score >= 50) metaCategoryPicker.setValue(matches[0].pathStr); + }).catch(() => {}); + } + } + }); + c.appendChild(btn); + } +} + +// --- Providers --- +function renderProviderButtons() { + const c = document.getElementById('provider-buttons'); + c.innerHTML = ''; + for (const p of state.providers) { + const btn = document.createElement('button'); + btn.className = 'toggle-btn' + (p.id === state.aiProvider ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.aiProvider = p.id; + state.aiModel = ''; + updateModelDropdown(); + }); + c.appendChild(btn); + } + updateModelDropdown(); +} + +function updateModelDropdown() { + const wrap = document.getElementById('model-select-wrap'); + const sel = document.getElementById('model-select'); + const prov = state.providers.find(p => p.id === state.aiProvider); + if (prov?.hasModels && prov.models?.length) { + const isOpenRouter = prov.id === 'openrouter'; + const options = isOpenRouter + ? `` + prov.models.map(m => ``).join('') + : prov.models.map(m => ``).join(''); + sel.innerHTML = options; + state.aiModel = isOpenRouter ? '' : prov.models[0].id; + sel.onchange = () => { state.aiModel = sel.value; }; + wrap.classList.remove('hidden'); + } else { + state.aiModel = ''; + wrap.classList.add('hidden'); + } +} + +// --- Upload --- +function setupUpload() { + const dz = document.getElementById('dropzone'); + const fi = document.getElementById('file-input'); + dz.addEventListener('click', () => fi.click()); + dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); }); + dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); + dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); + fi.addEventListener('change', () => { handleFiles(fi.files); fi.value = ''; }); + enablePhotoDragSort(); +} + +async function handleFiles(files) { + const available = Math.max(0, 10 - state.photos.length); + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/')); + if (!available) { toast('Maximal 10 Fotos pro Listing'); return; } + const accepted = imageFiles.slice(0, available); + if (imageFiles.length > available) toast(`Es wurden nur ${available} weitere Fotos übernommen`); + const fd = new FormData(); + fd.append('platform', state.platform || 'allgemein'); + for (const file of accepted) fd.append('photos', file); + if (!fd.has('photos')) return; + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(state.platform || 'allgemein')}`, { + method: 'POST', + body: fd, + headers: { 'X-CSRF-Token': getCsrf() }, + credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status})`); + state.photos.push(...(data.filenames || [])); + renderPhotoPreviews(); + updateGenerateButton(); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function renderPhotoPreviews() { + renderPhotoPreview(); +} + +function renderPhotoPreview() { + const c = document.getElementById('photo-preview'); + c.innerHTML = ''; + for (let i = 0; i < state.photos.length; i++) { + const div = document.createElement('div'); + div.className = 'thumb'; + div.draggable = true; + div.dataset.index = String(i + 1); + const safeUrl = photoUrl(state.photos[i]); + div.innerHTML = `Foto ${i + 1}`; + div.querySelector('img').addEventListener('click', e => { + e.stopPropagation(); + if (i > 0) { + const selected = state.photos.splice(i, 1)[0]; + state.photos.unshift(selected); + renderPhotoPreview(); + toast('Coverfoto geändert'); + } + }); + div.querySelector('.remove-photo').addEventListener('click', e => { + e.stopPropagation(); + state.photos.splice(i, 1); + renderPhotoPreview(); + updateGenerateButton(); + }); + div.querySelector('.edit-photo').addEventListener('click', e => { + e.stopPropagation(); + openPhotoEditor(i); + }); + c.appendChild(div); + } + document.getElementById('image-tools').classList.toggle('hidden', state.photos.length === 0); + updateGeneratorPhotoStage(); +} + +function updateGenerateButton() { + const ready = state.photos.length > 0; + document.getElementById('generate-btn').disabled = !ready; + if (!ready) { + state.aiModelPhotos = []; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + } else if (!state.aiModelPhotos.length) { + setAiModelStatus('generator-ai-model-status', 'Bereit für sichere AI-Model-Fotos.', 'neutral'); + } + updateGeneratorReadiness(); +} + +// --- Image tools --- +function setupImageTools() { + document.getElementById('advanced-image-editor-btn')?.addEventListener('click', () => { + if (!state.photos.length) return toast('Bitte zuerst mindestens ein Foto hochladen.'); + openPhotoEditor(0, 'generator'); + }); +} + +// --- Generate --- +function setupGenerate() { + document.getElementById('generate-btn').addEventListener('click', generateListing); + document.getElementById('reset-generator-btn').addEventListener('click', resetGenerator); + document.getElementById('language-select').addEventListener('change', e => { state.language = e.target.value; updateGeneratorReadiness(); }); + document.getElementById('variants-select').addEventListener('change', e => { state.variants = parseInt(e.target.value); }); + document.getElementById('template-select').addEventListener('change', e => applyGeneratorTemplate(e.target.value)); + document.getElementById('generator-ai-model-btn')?.addEventListener('click', runGeneratorAiModelPhotos); + document.getElementById('generator-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('generator')); + document.getElementById('generator-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('generator')); + updateAiModeControls('generator'); +} + +let generatorRenderSequence = 0; + +function getGeneratorListingForHtml() { + const tags = (document.getElementById('result-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: state.platform, + title: document.getElementById('result-title')?.value || '', + description: editors.result ? editors.result.getText().trim() : '', + description_html: getEditorHtml(editors.result), + tags, + brand: document.getElementById('meta-brand')?.value || null, + category: metaCategoryPicker?.getValue?.() || null, + size: document.getElementById('meta-size')?.value || null, + color: document.getElementById('meta-color')?.value || null, + condition: document.getElementById('meta-condition')?.value || null, + }; +} + +const refreshGeneratorPlatformPreview = debounce(async () => { + if (!state.currentListing || document.getElementById('result-section')?.classList.contains('hidden')) return; + const sequence = ++generatorRenderSequence; + setHtmlSyncStatus('generator-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const listing = getGeneratorListingForHtml(); + const data = await api('POST', '/api/html-render', { platform: state.platform, listing }); + if (sequence !== generatorRenderSequence) return; + state.currentHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + } catch (err) { + if (sequence !== generatorRenderSequence) return; + setSandboxPreview(document.getElementById('html-preview'), getEditorHtml(editors.result)); + setHtmlSyncStatus('generator-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupGeneratorHtmlSync() { + const editor = editors.result; + const source = document.getElementById('html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.generatorLock) return; + source.value = getEditorHtml(editor); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }); + + const applySourceToEditor = debounce(() => { + htmlSyncState.generatorLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.generatorLock = false; + source.value = safe; + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('generator-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('html-preview'), sanitizeRichHtmlClient(source.value)); + applySourceToEditor(); + }); +} + + +function setHtmlView(scope, view) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + const active = button.dataset.htmlView === view; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + const root = scope === 'generator' ? document.getElementById('html-preview-wrap') : document.querySelector('.detail-platform-workbench'); + root?.querySelectorAll('.html-switch-view').forEach(panel => panel.classList.toggle('is-active', panel.dataset.htmlPanel === view)); +} + +function bindHtmlViewTabs(scope) { + document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => { + if (button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', () => setHtmlView(scope, button.dataset.htmlView)); + }); +} + +function setupHtmlViewSwitches() { + bindHtmlViewTabs('generator'); + setHtmlView('generator', 'preview'); +} + +function setupMobileUpload() { + document.getElementById('generator-mobile-upload')?.addEventListener('click', () => openMobileUpload('generator')); + document.getElementById('mobile-upload-close')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-done')?.addEventListener('click', closeMobileUpload); + document.getElementById('mobile-upload-new-session')?.addEventListener('click', () => openMobileUpload(mobileUploadState.target?.context || 'generator', mobileUploadState.target?.listingId, true)); + document.getElementById('mobile-upload-copy-link')?.addEventListener('click', async () => { + const input = document.getElementById('mobile-upload-link'); + if (!input?.value) return; + await navigator.clipboard.writeText(input.value); + toast('Upload-Link kopiert'); + }); + document.getElementById('mobile-upload-overlay')?.addEventListener('click', event => { + if (event.target.id === 'mobile-upload-overlay') closeMobileUpload(); + }); +} + +async function openMobileUpload(context = 'generator', listingId = null, renew = false) { + if (renew && mobileUploadState.session?.id) { + try { await api('DELETE', `/api/mobile-upload-sessions/${mobileUploadState.session.id}`); } catch {} + } + stopMobileUploadPolling(); + mobileUploadState.target = { context, listingId: listingId || null }; + mobileUploadState.imported = new Set(context === 'listing' ? (detailEditorState?.photos || []) : state.photos); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.remove('hidden'); + overlay?.setAttribute('aria-hidden', 'false'); + setMobileUploadDialogState('Link wird vorbereitet …', 'working'); + try { + const session = await api('POST', '/api/mobile-upload-sessions', { context, listing_id: listingId || null }); + mobileUploadState.session = session; + const link = document.getElementById('mobile-upload-link'); + if (link) link.value = session.url; + const qrCanvas = document.getElementById('mobile-upload-qr'); + if (typeof QRious !== 'undefined' && qrCanvas) new QRious({ element: qrCanvas, value: session.url, size: 240, level: 'M', foreground: '#171714', background: '#ffffff' }); + const expiry = new Date(session.expires_at); + document.getElementById('mobile-upload-expiry').textContent = `Gültig bis ${expiry.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + const localOnly = /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(session.url); + const networkNote = document.getElementById('mobile-upload-network-note'); + if (networkNote) { + networkNote.textContent = localOnly + ? 'Keine LAN-IP erkannt. Prüfe Windows-Firewall und Netzwerkadapter oder setze PUBLIC_BASE_URL manuell.' + : `Handy und Vendoo-PC müssen im selben Netzwerk sein. Erkannte Adresse: ${session.base_url || session.url.split('/mobile-upload/')[0]}`; + } + setMobileUploadDialogState(localOnly ? 'LAN-Adresse prüfen' : 'Bereit zum Scannen', localOnly ? 'warning' : 'ready'); + updateMobileUploadCount([]); + mobileUploadState.pollTimer = window.setInterval(pollMobileUploadSession, 1500); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + } +} + +function setMobileUploadDialogState(text, tone) { + const element = document.getElementById('mobile-upload-session-state'); + if (!element) return; + element.lastChild.textContent = text; + element.dataset.tone = tone; +} + +function updateMobileUploadCount(files) { + const count = document.getElementById('mobile-upload-file-count'); + if (count) count.textContent = `${files.length} ${files.length === 1 ? 'Bild' : 'Bilder'} empfangen`; +} + +async function pollMobileUploadSession() { + const session = mobileUploadState.session; + if (!session?.id) return; + try { + const data = await api('GET', `/api/mobile-upload-sessions/${session.id}`); + const files = Array.isArray(data.files) ? data.files : []; + const fresh = files.filter(file => !mobileUploadState.imported.has(file)); + fresh.forEach(file => mobileUploadState.imported.add(file)); + if (fresh.length) { + if (mobileUploadState.target?.context === 'listing' && detailEditorState) { + detailEditorState.photos = [...new Set([...detailEditorState.photos, ...fresh])].slice(0, 10); + renderDetailPhotos(); + } else { + state.photos = [...new Set([...state.photos, ...fresh])].slice(0, 10); + renderPhotoPreviews(); + updateGeneratorReadiness(); + } + toast(`${fresh.length} ${fresh.length === 1 ? 'Bild empfangen' : 'Bilder empfangen'}`); + } + updateMobileUploadCount(files); + if (files.length) setMobileUploadDialogState('Bilder werden live übertragen', 'ready'); + } catch (error) { + setMobileUploadDialogState(error.message, 'error'); + stopMobileUploadPolling(); + } +} + +function stopMobileUploadPolling() { + if (mobileUploadState.pollTimer) window.clearInterval(mobileUploadState.pollTimer); + mobileUploadState.pollTimer = null; +} + +function closeMobileUpload() { + stopMobileUploadPolling(); + const overlay = document.getElementById('mobile-upload-overlay'); + overlay?.classList.add('hidden'); + overlay?.setAttribute('aria-hidden', 'true'); +} + +function setupGeneratorStudio() { + const notes = document.getElementById('generator-seller-notes'); + notes?.addEventListener('input', () => { + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes.value.length} / 500`; + }); + document.getElementById('result-title')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('result-tags')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + document.getElementById('meta-price')?.addEventListener('input', debounce(updateGeneratorFeePreview, 250)); + document.getElementById('generator-download-photos')?.addEventListener('click', () => downloadPublishPhotos(state.photos)); + ['meta-brand', 'meta-size', 'meta-color', 'meta-condition'].forEach(id => { + document.getElementById(id)?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); }); + }); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +function applyGeneratorTemplate(value) { + const tpl = state.templates.find(t => t.id === parseInt(value)); + const notes = document.getElementById('generator-seller-notes'); + const tags = document.getElementById('generator-template-tags'); + if (!tpl) { + if (notes) notes.value = ''; + if (tags) { tags.innerHTML = ''; tags.classList.add('hidden'); } + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = '0 / 500'; + return; + } + if (tpl.platform) { + state.platform = tpl.platform; + renderPlatformButtons(); + } + if (tpl.language) { + state.language = tpl.language; + document.getElementById('language-select').value = tpl.language; + } + if (notes) notes.value = (tpl.seller_notes || '').slice(0, 500); + const count = document.getElementById('generator-notes-count'); + if (count) count.textContent = `${notes?.value.length || 0} / 500`; + const defaultTags = Array.isArray(tpl.default_tags) ? tpl.default_tags : []; + if (tags) { + tags.innerHTML = defaultTags.map(tag => `${escapeHtml(tag)}`).join(''); + tags.classList.toggle('hidden', defaultTags.length === 0); + } + updateGeneratorReadiness(); +} + +function updateGeneratorPhotoStage() { + const count = state.photos.length; + const cover = document.getElementById('generator-cover'); + const empty = document.getElementById('generator-photo-empty'); + const badge = document.getElementById('generator-cover-badge'); + const countEl = document.getElementById('generator-photo-count'); + if (countEl) countEl.textContent = `${count} / 10`; + if (cover && empty && badge) { + if (count) { + cover.src = photoUrl(state.photos[0]); + cover.classList.remove('hidden'); + empty.classList.add('hidden'); + badge.classList.remove('hidden'); + } else { + cover.removeAttribute('src'); + cover.classList.add('hidden'); + empty.classList.remove('hidden'); + badge.classList.add('hidden'); + } + } + const completed = { + count: count >= 3, + cover: count >= 1, + details: count >= 4, + background: count >= 2, + order: count >= 2, + }; + let score = 0; + document.querySelectorAll('#generator-photo-tips [data-photo-tip]').forEach(item => { + const done = Boolean(completed[item.dataset.photoTip]); + item.classList.toggle('is-complete', done); + if (done) score += 1; + }); + const scoreEl = document.getElementById('generator-photo-quality-score'); + if (scoreEl) scoreEl.textContent = `${score} / 5`; + document.getElementById('generator-step-photos')?.classList.toggle('is-complete', count > 0); +} + +function updateGeneratorReadiness() { + const wrapper = document.querySelector('.generator-readiness'); + const title = document.getElementById('generator-readiness-title'); + const copy = document.getElementById('generator-readiness-copy'); + const ready = state.photos.length > 0; + wrapper?.classList.toggle('is-ready', ready); + if (title) title.textContent = ready ? 'Bereit zur Analyse' : 'Fotos fehlen'; + if (copy) copy.textContent = ready + ? `${state.photos.length} Foto${state.photos.length === 1 ? '' : 's'} · ${platformLabel(state.platform)} · ${state.language.toUpperCase()}` + : 'Lade mindestens ein Produktfoto hoch.'; + document.getElementById('generator-step-ai')?.classList.toggle('is-active', ready && !state.currentListing); +} + +function updateGeneratorResultMetrics() { + const title = document.getElementById('result-title')?.value || ''; + const description = editors.result ? editors.result.getText().trim() : ''; + const tagValue = document.getElementById('result-tags')?.value || ''; + const tags = tagValue.split(',').map(tag => tag.trim()).filter(Boolean); + const titleCount = document.getElementById('result-title-count'); + const descriptionCount = document.getElementById('result-description-count'); + const tagsCount = document.getElementById('result-tags-count'); + if (titleCount) titleCount.textContent = `${title.length} / 80`; + if (descriptionCount) descriptionCount.textContent = `${description.length} Zeichen`; + if (tagsCount) tagsCount.textContent = `${tags.length} Tag${tags.length === 1 ? '' : 's'}`; + + let score = 0; + if (title.length >= 30 && title.length <= 80) score += 25; + else if (title.length >= 15) score += 16; + if (description.length >= 180) score += 30; + else if (description.length >= 80) score += 18; + if (tags.length >= 5) score += 15; + else if (tags.length) score += 8; + if (document.getElementById('meta-brand')?.value) score += 8; + if (document.getElementById('meta-category-picker')?.textContent?.trim()) score += 8; + if (parseFloat(document.getElementById('meta-price')?.value)) score += 8; + if (state.photos.length >= 3) score += 6; + score = Math.min(100, score); + const scoreEl = document.getElementById('generator-quality-score'); + const ring = document.getElementById('generator-quality-ring'); + const label = document.getElementById('generator-quality-label'); + if (scoreEl) scoreEl.textContent = `${score}%`; + if (ring) ring.style.setProperty('--quality', `${score}%`); + if (label) { + label.textContent = score >= 85 ? 'Sehr gut' : score >= 65 ? 'Gut' : score >= 40 ? 'Ausbaufähig' : 'Unvollständig'; + label.style.color = score >= 65 ? 'var(--vd-success)' : score >= 40 ? 'var(--vd-warning)' : 'var(--vd-danger)'; + } +} + +async function updateGeneratorFeePreview() { + const price = parseFloat(document.getElementById('meta-price')?.value) || 0; + const feeEl = document.getElementById('fee-info'); + if (!feeEl || !price) { + feeEl?.classList.add('hidden'); + updateGeneratorResultMetrics(); + return; + } + try { + const fees = await api('GET', `/api/fees?platform=${encodeURIComponent(state.platform)}&price=${encodeURIComponent(price)}`); + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${escapeHtml(fees.breakdown || '')}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } catch {} + updateGeneratorResultMetrics(); +} + +async function generateListing() { + const btn = document.getElementById('generate-btn'); + const loading = document.getElementById('loading'); + + btn.disabled = true; + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Analysiert'; previewStatus.className = 'generator-preview-status is-working'; } + document.getElementById('generator-step-ai')?.classList.add('is-active'); + loading.classList.remove('hidden'); + + try { + const body = { + photos: state.photos, + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + variants: state.variants, + template_id: parseInt(document.getElementById('template-select')?.value) || null, + seller_notes: document.getElementById('generator-seller-notes')?.value.trim() || '', + }; + if (state.aiModel) body.ai_model = state.aiModel; + + const data = await api('POST', '/api/generate', body); + + if (data.meta) showMeta(data.meta, data.fees); + + if (data.variants) { + state.allVariants = data.variants; + showVariantTabs(data.variants); + showListing(data.variants[0], data.variants[0].description_html); + } else { + state.allVariants = null; + document.getElementById('variants-section').classList.add('hidden'); + showListing(data.listing, data.description_html); + } + setTimeout(fetchPriceSuggestion, 500); + } catch (err) { + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Fehler'; previewStatus.className = 'generator-preview-status'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + toast('Fehler: ' + err.message); + } finally { + loading.classList.add('hidden'); + btn.disabled = state.photos.length === 0; + updateGeneratorReadiness(); + } +} + +async function showMeta(meta, fees) { + document.getElementById('meta-sku').value = state.currentListing?.sku || ''; + document.getElementById('meta-brand').value = meta.brand || ''; + document.getElementById('meta-size').value = meta.size || ''; + document.getElementById('meta-color').value = meta.color || ''; + document.getElementById('meta-condition').value = meta.condition || ''; + document.getElementById('meta-price').value = meta.suggested_price || ''; + document.getElementById('meta-section').classList.remove('hidden'); + updateGeneratorResultMetrics(); + + let matchedCategory = null; + if (meta.category) { + try { + const matches = await api('GET', `/api/categories/${state.platform}/match?q=${encodeURIComponent(meta.category)}`); + if (matches.length && matches[0].score >= 50) matchedCategory = matches[0].pathStr; + } catch {} + } + metaCategoryPicker = createCategoryPicker('meta-category-picker', state.platform, matchedCategory || meta.category || null); + + const feeEl = document.getElementById('fee-info'); + if (fees) { + feeEl.innerHTML = `Gebühren: ${fees.fees} € (${fees.breakdown}) → Netto: ${fees.net} €`; + feeEl.classList.remove('hidden'); + } else { feeEl.classList.add('hidden'); } +} + +function getEditedMeta() { + return { + brand: document.getElementById('meta-brand').value || null, + category: (metaCategoryPicker ? metaCategoryPicker.getValue() : null) || null, + size: document.getElementById('meta-size').value || null, + color: document.getElementById('meta-color').value || null, + condition: document.getElementById('meta-condition').value || null, + price: parseFloat(document.getElementById('meta-price').value) || null, + storage_location: document.getElementById('meta-storage-location').value || null, + }; +} + +function showVariantTabs(variants) { + const c = document.getElementById('variant-tabs'); + c.innerHTML = ''; + variants.forEach((v, i) => { + const btn = document.createElement('button'); + btn.className = 'variant-tab' + (i === 0 ? ' active' : ''); + btn.textContent = `Variante ${i + 1}`; + btn.addEventListener('click', () => { + c.querySelectorAll('.variant-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showListing(v, v.description_html); + }); + c.appendChild(btn); + }); + document.getElementById('variants-section').classList.remove('hidden'); +} + +function showListing(listing, platformHtml) { + state.currentListing = listing; + const editorHtml = listing.id && listing.description_html + ? sanitizeRichHtmlClient(listing.description_html) + : plainTextToRichHtml(listing.description || ''); + state.currentHtml = platformHtml || ''; + + const skuEl = document.getElementById('meta-sku'); + if (skuEl && listing.sku) skuEl.value = listing.sku; + document.getElementById('result-title').value = listing.title || ''; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, editorHtml); + htmlSyncState.generatorLock = false; + document.getElementById('html-source').value = getEditorHtml(editors.result); + setSandboxPreview(document.getElementById('html-preview'), state.currentHtml || editorHtml); + document.getElementById('html-preview-wrap')?.classList.remove('hidden'); + setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok'); + + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + document.getElementById('result-tags').value = tags; + const plat = state.platforms.find(p => p.id === state.platform); + document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags'; + document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen'; + document.getElementById('generator-preview-empty')?.classList.add('hidden'); + document.getElementById('result-section').classList.remove('hidden'); + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Bereit zur Prüfung'; previewStatus.className = 'generator-preview-status is-ready'; } + document.getElementById('generator-step-ai')?.classList.remove('is-active'); + document.getElementById('generator-step-ai')?.classList.add('is-complete'); + document.getElementById('generator-step-result')?.classList.add('is-active'); + updateGeneratorResultMetrics(); + refreshGeneratorPlatformPreview(); +} + +// --- Copy --- +function setupCopy() { + document.querySelectorAll('.copy-btn[data-target]').forEach(btn => { + btn.addEventListener('click', () => { + navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value); + toast('Kopiert!'); + }); + }); + document.getElementById('copy-desc-text').addEventListener('click', () => { + if (editors.result) { + navigator.clipboard.writeText(editors.result.getText().trim()); + toast('Text kopiert!'); + } + }); + document.getElementById('copy-desc-html').addEventListener('click', () => { + const src = document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Editor-HTML kopiert!'); + } + }); + document.getElementById('copy-platform-html')?.addEventListener('click', () => { + const src = state.currentHtml || document.getElementById('html-source').value; + if (src) { + navigator.clipboard.writeText(src); + toast('Plattform-HTML kopiert!'); + } + }); + document.getElementById('format-html-source')?.addEventListener('click', () => { + const source = document.getElementById('html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.generatorLock = true; + setEditorHtml(editors.result, safe); + htmlSyncState.generatorLock = false; + refreshGeneratorPlatformPreview(); + toast('HTML bereinigt und synchronisiert'); + }); + document.getElementById('copy-all-btn').addEventListener('click', () => { + const t = document.getElementById('result-title').value; + const d = editors.result ? editors.result.getText().trim() : ''; + const tg = document.getElementById('result-tags').value; + navigator.clipboard.writeText(`${t}\n\n${d}\n\n${tg}`); + toast('Alles kopiert!'); + }); + document.getElementById('save-btn').addEventListener('click', async () => { + if (!state.currentListing) return; + const tags = document.getElementById('result-tags').value.split(',').map(t => t.trim()).filter(Boolean); + const meta = getEditedMeta(); + const descriptionHtml = getEditorHtml(editors.result); + const description = richHtmlToText(descriptionHtml); + const payload = { + title: document.getElementById('result-title').value, + description, + description_html: descriptionHtml, + tags, + photos: [...state.photos], + platform: state.platform, + ai_provider: state.aiProvider, + language: state.language, + ...meta, + }; + try { + if (state.currentListing.id) { + await api('PUT', `/api/listings/${state.currentListing.id}`, { ...payload, _edit_lock: listingEditLock.listingId === Number(state.currentListing.id) ? listingEditLock.token : null }); + toast('Gespeichert!'); + } else { + await api('POST', '/api/listings', { + ...state.currentListing, + ...payload, + }); + toast('Listing hinzugefügt!'); + notify('Vendoo', `"${payload.title}" gespeichert`); + resetGenerator(); + } + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function resetGenerator() { + state.currentListing = null; + state.currentHtml = ''; + state.allVariants = null; + state.photos = []; + state.aiModelPhotos = []; + metaCategoryPicker = null; + document.getElementById('photo-preview').innerHTML = ''; + document.getElementById('image-tools').classList.add('hidden'); + document.getElementById('result-section').classList.add('hidden'); + document.getElementById('meta-section').classList.add('hidden'); + document.getElementById('variants-section').classList.add('hidden'); + document.getElementById('generator-preview-empty')?.classList.remove('hidden'); + document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen'; + document.getElementById('generate-btn').disabled = true; + renderAiModelGrid('generator', []); + setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); + document.getElementById('template-select').value = ''; + const notes = document.getElementById('generator-seller-notes'); + if (notes) notes.value = ''; + const notesCount = document.getElementById('generator-notes-count'); + if (notesCount) notesCount.textContent = '0 / 500'; + const templateTags = document.getElementById('generator-template-tags'); + if (templateTags) { templateTags.innerHTML = ''; templateTags.classList.add('hidden'); } + if (editors.result) editors.result.setText(''); + const htmlSource = document.getElementById('html-source'); + if (htmlSource) htmlSource.value = ''; + setSandboxPreview(document.getElementById('html-preview'), ''); + document.getElementById('result-title').value = ''; + document.getElementById('result-tags').value = ''; + const previewStatus = document.getElementById('generator-preview-status'); + if (previewStatus) { previewStatus.textContent = 'Wartet'; previewStatus.className = 'generator-preview-status'; } + ['generator-step-ai', 'generator-step-result'].forEach(id => { + document.getElementById(id)?.classList.remove('is-active', 'is-complete'); + }); + document.getElementById('generator-step-photos')?.classList.remove('is-complete'); + document.getElementById('generator-step-photos')?.classList.add('is-active'); + updateGeneratorPhotoStage(); + updateGeneratorReadiness(); + updateGeneratorResultMetrics(); +} + +// --- Listings / Historie --- +const historyState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + hiddenColumns: new Set(), +}; + +const HISTORY_COLUMN_STORAGE = 'vendoo-history-columns'; + +function setupHistory() { + const search = document.getElementById('search-input'); + const platform = document.getElementById('filter-platform'); + const status = document.getElementById('filter-status'); + const period = document.getElementById('history-period'); + const dateFrom = document.getElementById('filter-date-from'); + const dateTo = document.getElementById('filter-date-to'); + + search.addEventListener('input', debounce(() => { state.historyPage = 1; loadHistory(); }, 300)); + platform.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + status.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + period.addEventListener('change', () => { + applyHistoryPeriod(period.value); + state.historyPage = 1; + loadHistory(); + }); + dateFrom.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + dateTo.addEventListener('change', () => { state.historyPage = 1; loadHistory(); }); + + document.getElementById('history-reset-filters').addEventListener('click', () => { + search.value = ''; + platform.value = ''; + status.value = ''; + period.value = '90'; + applyHistoryPeriod('90'); + state.historyPage = 1; + loadHistory(); + }); + + document.getElementById('back-to-list').addEventListener('click', async () => { await releaseListingLock({ quiet: true }); setHistoryDetailMode(false); }); + + document.getElementById('history-select-all').addEventListener('change', e => { + const checked = e.target.checked; + historyState.listings.forEach(listing => { + if (checked) historyState.selected.add(listing.id); + else historyState.selected.delete(listing.id); + }); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = checked; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.toggle('selected', checked)); + updateHistoryToolbar(); + }); + + document.getElementById('history-clear-selection').addEventListener('click', () => { + historyState.selected.clear(); + document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = false; }); + document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.remove('selected')); + document.getElementById('history-select-all').checked = false; + updateHistoryToolbar(); + }); + + document.getElementById('history-delete-selected').addEventListener('click', deleteSelectedHistoryListings); + document.getElementById('history-delete-all').addEventListener('click', async () => { + if (!historyState.allFiltered.length) return; + if (!confirm(`Alle ${historyState.allFiltered.length} gefilterten Artikel in den Papierkorb verschieben?`)) return; + for (const listing of historyState.allFiltered) await api('DELETE', `/api/listings/${listing.id}`); + historyState.selected.clear(); + toast('Alle gefilterten Artikel wurden in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + }); + + document.getElementById('history-bulk-status').addEventListener('click', bulkUpdateHistoryStatus); + document.getElementById('history-price-selected').addEventListener('click', bulkUpdateHistoryPrice); + document.getElementById('history-duplicate-selected').addEventListener('click', bulkDuplicateHistoryListings); + document.getElementById('history-edit-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (ids.length !== 1) { toast('Zum Nachbearbeiten bitte genau ein Artikel auswählen'); return; } + showDetail(ids[0]); + }); + document.getElementById('history-publish-selected').addEventListener('click', () => { + const ids = [...historyState.selected]; + if (!ids.length) return; + publishState.selected = new Set(ids); + switchToTab('publish'); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} für Publish ausgewählt`); + }); + document.getElementById('history-print-labels').addEventListener('click', () => { + const selected = historyState.allFiltered.filter(listing => historyState.selected.has(listing.id)); + if (selected.length) printLabels(selected); + }); + + document.getElementById('export-csv-btn').addEventListener('click', () => { + const params = new URLSearchParams(); + if (platform.value) params.set('platform', platform.value); + if (status.value) params.set('status', status.value); + window.open(`/api/export/csv?${params}`, '_blank'); + closeHistoryPopovers(); + }); + document.getElementById('export-json-btn').addEventListener('click', () => { + const data = historyState.allFiltered; + if (!data.length) { toast('Keine Artikel zum Exportieren'); return; } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const anchor = document.createElement('a'); + anchor.href = URL.createObjectURL(blob); + anchor.download = `vendoo-export-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(anchor.href); + toast(`${data.length} Artikel als JSON exportiert`); + closeHistoryPopovers(); + }); + + setupHistoryPopovers(); + setupHistoryColumns(); + + const pageSizeSelect = document.getElementById('history-page-size-select'); + pageSizeSelect.value = String(state.historyPageSize); + pageSizeSelect.addEventListener('change', () => { + state.historyPageSize = Number(pageSizeSelect.value); + state.historyPage = 1; + loadHistory(); + }); + + setupPagination('history'); + applyHistoryPeriod(period.value || '90'); +} + +function applyHistoryPeriod(value) { + const custom = document.getElementById('history-custom-dates'); + const fromInput = document.getElementById('filter-date-from'); + const toInput = document.getElementById('filter-date-to'); + custom.classList.toggle('hidden', value !== 'custom'); + if (value === 'custom') return; + + const today = new Date(); + const iso = date => date.toISOString().slice(0, 10); + toInput.value = value === 'all' ? '' : iso(today); + + if (value === 'all') { + fromInput.value = ''; + } else if (value === 'year') { + fromInput.value = `${today.getFullYear()}-01-01`; + } else { + const days = Number(value) || 90; + const from = new Date(today); + from.setDate(from.getDate() - days + 1); + fromInput.value = iso(from); + } +} + +function setupHistoryPopovers() { + const exportButton = document.getElementById('history-export-toggle'); + const exportMenu = document.getElementById('history-export-menu'); + const columnsButton = document.getElementById('history-columns-btn'); + const columnsPanel = document.getElementById('history-columns-panel'); + + exportButton.addEventListener('click', event => { + event.stopPropagation(); + const open = exportMenu.classList.toggle('hidden') === false; + columnsPanel.classList.add('hidden'); + exportButton.setAttribute('aria-expanded', String(open)); + columnsButton.setAttribute('aria-expanded', 'false'); + }); + columnsButton.addEventListener('click', event => { + event.stopPropagation(); + const open = columnsPanel.classList.toggle('hidden') === false; + exportMenu.classList.add('hidden'); + columnsButton.setAttribute('aria-expanded', String(open)); + exportButton.setAttribute('aria-expanded', 'false'); + }); + exportMenu.addEventListener('click', event => event.stopPropagation()); + columnsPanel.addEventListener('click', event => event.stopPropagation()); + document.addEventListener('click', closeHistoryPopovers); +} + +function closeHistoryPopovers() { + document.getElementById('history-export-menu')?.classList.add('hidden'); + document.getElementById('history-columns-panel')?.classList.add('hidden'); + document.getElementById('history-export-toggle')?.setAttribute('aria-expanded', 'false'); + document.getElementById('history-columns-btn')?.setAttribute('aria-expanded', 'false'); + document.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); +} + +function setupHistoryColumns() { + try { + const saved = JSON.parse(localStorage.getItem(HISTORY_COLUMN_STORAGE) || '[]'); + historyState.hiddenColumns = new Set(Array.isArray(saved) ? saved : []); + } catch { historyState.hiddenColumns = new Set(); } + + document.querySelectorAll('[data-history-column]').forEach(input => { + input.checked = !historyState.hiddenColumns.has(input.dataset.historyColumn); + input.addEventListener('change', () => { + const column = input.dataset.historyColumn; + if (input.checked) historyState.hiddenColumns.delete(column); + else historyState.hiddenColumns.add(column); + localStorage.setItem(HISTORY_COLUMN_STORAGE, JSON.stringify([...historyState.hiddenColumns])); + applyHistoryColumns(); + }); + }); + applyHistoryColumns(); +} + +function applyHistoryColumns() { + const table = document.getElementById('history-table'); + if (!table) return; + ['platform', 'provider', 'price', 'status', 'location', 'published'].forEach(column => { + table.classList.toggle(`history-hide-${column}`, historyState.hiddenColumns.has(column)); + }); +} + +function setHistoryDetailMode(enabled) { + document.querySelector('.listings-heading')?.classList.toggle('hidden', enabled); + document.querySelector('.history-filterbar')?.classList.toggle('hidden', enabled); + document.querySelector('.history-table-shell')?.classList.toggle('hidden', enabled); + document.getElementById('history-detail')?.classList.toggle('hidden', !enabled); +} + +async function deleteSelectedHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + if (!confirm(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschieben?`)) return; + for (const id of ids) await api('DELETE', `/api/listings/${id}`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschoben`); + await loadHistory(); + updateTrashBadge(); +} + +async function bulkUpdateHistoryStatus() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Status: aktiv, verkauft oder reserviert'); + if (!answer) return; + const normalized = answer.trim().toLowerCase(); + const map = { aktiv: 'active', active: 'active', verkauft: 'sold', sold: 'sold', reserviert: 'reserved', reserved: 'reserved' }; + const newStatus = map[normalized]; + if (!newStatus) { toast('Unbekannter Status'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { status: newStatus }))); + toast(`Status für ${ids.length} Artikel aktualisiert`); + loadHistory(); +} + +async function bulkUpdateHistoryPrice() { + const ids = [...historyState.selected]; + if (!ids.length) return; + const answer = prompt('Neuer Preis in Euro für alle ausgewählten Artikel:'); + if (answer === null) return; + const price = Number(String(answer).replace(',', '.')); + if (!Number.isFinite(price) || price < 0) { toast('Bitte einen gültigen Preis eingeben'); return; } + await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { price }))); + toast(`Preis für ${ids.length} Artikel aktualisiert`); + loadHistory(); +} + +async function bulkDuplicateHistoryListings() { + const ids = [...historyState.selected]; + if (!ids.length) return; + for (const id of ids) await api('POST', `/api/listings/${id}/duplicate`); + historyState.selected.clear(); + toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} dupliziert`); + loadHistory(); +} + +function setupPagination(prefix) { + const topBar = document.getElementById(`${prefix}-pagination-top`); + if (!topBar) return; + topBar.querySelectorAll('.page-size-btn').forEach(btn => { + btn.addEventListener('click', () => { + topBar.querySelectorAll('.page-size-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const size = parseInt(btn.dataset.size); + if (prefix === 'history') { state.historyPageSize = size; state.historyPage = 1; loadHistory(); } + else { state.publishPageSize = size; state.publishPage = 1; loadPublishList(); } + }); + }); + document.getElementById(`${prefix}-prev`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); + document.getElementById(`${prefix}-prev-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage--; loadHistory(); } + else { state.publishPage--; loadPublishList(); } + }); + document.getElementById(`${prefix}-next-bottom`)?.addEventListener('click', () => { + if (prefix === 'history') { state.historyPage++; loadHistory(); } + else { state.publishPage++; loadPublishList(); } + }); +} + +function updatePaginationUI(prefix, page, totalPages, totalItems) { + const info = `Seite ${page} von ${totalPages} (${totalItems} Einträge)`; + document.getElementById(`${prefix}-page-info`)?.replaceChildren(document.createTextNode(info)); + document.getElementById(`${prefix}-page-info-bottom`)?.replaceChildren(document.createTextNode(info)); + + const prevDisabled = page <= 1; + const nextDisabled = page >= totalPages; + const prev = document.getElementById(`${prefix}-prev`); + const next = document.getElementById(`${prefix}-next`); + const prevBottom = document.getElementById(`${prefix}-prev-bottom`); + const nextBottom = document.getElementById(`${prefix}-next-bottom`); + if (prev) prev.disabled = prevDisabled; + if (next) next.disabled = nextDisabled; + if (prevBottom) prevBottom.disabled = prevDisabled; + if (nextBottom) nextBottom.disabled = nextDisabled; + + if (prefix === 'history') { + const totalInfo = document.getElementById('history-total-info'); + if (totalInfo) totalInfo.textContent = `von ${totalItems} Einträgen`; + renderHistoryPageButtons(page, totalPages); + } +} + +function renderHistoryPageButtons(page, totalPages) { + const container = document.getElementById('history-page-buttons'); + if (!container) return; + const pages = new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2]); + const valid = [...pages].filter(value => value >= 1 && value <= totalPages).sort((a, b) => a - b); + const parts = []; + let previous = 0; + for (const value of valid) { + if (previous && value - previous > 1) parts.push(''); + parts.push(``); + previous = value; + } + container.innerHTML = parts.join(''); + container.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => { + state.historyPage = Number(button.dataset.page); + loadHistory(); + })); +} + +function paginateList(allItems, page, pageSize) { + if (!pageSize || pageSize <= 0) return { items: allItems, page: 1, totalPages: 1 }; + const totalPages = Math.max(1, Math.ceil(allItems.length / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * pageSize; + return { items: allItems.slice(start, start + pageSize), page: safePage, totalPages }; +} + +async function loadHistory() { + const query = document.getElementById('search-input').value.trim(); + const platform = document.getElementById('filter-platform').value; + const status = document.getElementById('filter-status').value; + const dateFrom = document.getElementById('filter-date-from').value; + const dateTo = document.getElementById('filter-date-to').value; + + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + let listings = await api('GET', `/api/listings?${params.toString()}`); + + if (dateFrom) { + const from = new Date(`${dateFrom}T00:00:00`); + listings = listings.filter(listing => historyParseDate(listing.created_at) >= from); + } + if (dateTo) { + const to = new Date(`${dateTo}T23:59:59.999`); + listings = listings.filter(listing => historyParseDate(listing.created_at) <= to); + } + + historyState.allFiltered = listings; + const { items, page, totalPages } = paginateList(listings, state.historyPage, state.historyPageSize); + state.historyPage = page; + historyState.listings = items; + updatePaginationUI('history', page, totalPages, listings.length); + + if (items.length) { + const ids = items.map(listing => listing.id).join(','); + try { historyState.publishStatus = await api('GET', `/api/publish/status?ids=${ids}`); } + catch { historyState.publishStatus = {}; } + } else { + historyState.publishStatus = {}; + } + + renderHistoryList(items); +} + +function historyParseDate(value) { + if (!value) return new Date(0); + const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value.replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? new Date(0) : date; +} + +function historyFormatDate(value, withTime = false) { + const date = historyParseDate(value); + if (!date.getTime()) return '—'; + return date.toLocaleString('de-DE', withTime + ? { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' } + : { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function historyProviderLabel(provider) { + return ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || '—'; +} + +function historyStatusMeta(status) { + return ({ + active: { label: 'Aktiv', className: 'active' }, + sold: { label: 'Verkauft', className: 'sold' }, + reserved: { label: 'Reserviert', className: 'reserved' }, + })[status] || { label: status || 'Aktiv', className: 'active' }; +} + +function historyPublishSummary(listing) { + const statuses = historyState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return 'Nicht veröffentlicht'; + return entries.slice(0, 2).map(([platform, status]) => { + const meta = { + published: ['Live', 'published'], + copied: ['Kopiert', 'copied'], + pending: ['Ausstehend', 'pending'], + failed: ['Fehler', 'failed'], + }[status] || [status, 'pending']; + return `${escapeHtml(platformLabel(platform))}${escapeHtml(meta[0])}`; + }).join(''); +} + +function renderHistoryList(listings) { + const container = document.getElementById('history-list'); + if (!listings.length) { + container.innerHTML = `
                                                                      +
                                                                      +

                                                                      Keine Artikel gefunden

                                                                      +

                                                                      Ändere die Filter oder erstelle ein neues Listing.

                                                                      + +
                                                                      `; + container.querySelector('[data-empty-new-listing]')?.addEventListener('click', () => switchToTab('generator')); + updateHistoryToolbar(); + applyHistoryColumns(); + return; + } + + container.innerHTML = listings.map(listing => { + const checked = historyState.selected.has(listing.id); + const photo = listing.photos?.[0] + ? `` + : ''; + const status = historyStatusMeta(listing.status || 'active'); + const provider = historyProviderLabel(listing.ai_provider); + const platform = platformLabel(listing.platform); + const price = listing.price !== null && listing.price !== undefined ? formatCurrency(listing.price) : '—'; + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + + return `
                                                                      +
                                                                      +
                                                                      + ${photo} +
                                                                      + +
                                                                      SKU  ${escapeHtml(sku)}
                                                                      +
                                                                      +
                                                                      +
                                                                      ${escapeHtml(platform)}${escapeHtml((listing.language || 'de').toUpperCase())}
                                                                      +
                                                                      ${escapeHtml(provider.slice(0, 1))}${escapeHtml(provider)}Vision AI
                                                                      +
                                                                      ${escapeHtml(price)}Festpreis
                                                                      +
                                                                      + + Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))} +
                                                                      +
                                                                      ${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}${escapeHtml(listing.category || '—')}
                                                                      +
                                                                      ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}
                                                                      ${historyPublishSummary(listing)}
                                                                      +
                                                                      + + +
                                                                      +
                                                                      `; + }).join(''); + + container.querySelectorAll('.history-row-select').forEach(checkbox => { + checkbox.addEventListener('click', event => event.stopPropagation()); + checkbox.addEventListener('change', () => { + const id = Number(checkbox.dataset.id); + if (checkbox.checked) historyState.selected.add(id); + else historyState.selected.delete(id); + checkbox.closest('.history-row')?.classList.toggle('selected', checkbox.checked); + updateHistoryToolbar(); + }); + }); + + container.querySelectorAll('[data-open-listing]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + showDetail(Number(button.dataset.openListing)); + })); + + container.querySelectorAll('.history-row').forEach(row => row.addEventListener('dblclick', event => { + if (!event.target.closest('button, input, select')) showDetail(Number(row.dataset.id)); + })); + + container.querySelectorAll('.history-status-select').forEach(select => { + select.addEventListener('click', event => event.stopPropagation()); + select.addEventListener('change', async () => { + await api('PUT', `/api/listings/${select.dataset.id}`, { status: select.value }); + toast('Status aktualisiert'); + loadHistory(); + }); + }); + + container.querySelectorAll('[data-row-menu]').forEach(button => button.addEventListener('click', event => { + event.stopPropagation(); + const panel = container.querySelector(`[data-row-menu-panel="${button.dataset.rowMenu}"]`); + const willOpen = panel?.classList.contains('hidden'); + container.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden')); + if (willOpen) panel?.classList.remove('hidden'); + })); + + container.querySelectorAll('[data-row-action]').forEach(button => button.addEventListener('click', async event => { + event.stopPropagation(); + const id = Number(button.dataset.id); + const action = button.dataset.rowAction; + if (action === 'open') showDetail(id); + if (action === 'duplicate') { + const copy = await api('POST', `/api/listings/${id}/duplicate`); + toast(`Kopie erstellt: „${copy.title}“`); + loadHistory(); + } + if (action === 'delete') { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${id}`); + historyState.selected.delete(id); + toast('Listing in den Papierkorb verschoben'); + await loadHistory(); + updateTrashBadge(); + } + })); + + document.getElementById('history-select-all').checked = listings.every(listing => historyState.selected.has(listing.id)); + updateHistoryToolbar(); + applyHistoryColumns(); +} + +function updateHistoryToolbar() { + const count = historyState.selected.size; + const countLabel = document.getElementById('history-selected-count'); + if (countLabel) countLabel.textContent = `${count} ausgewählt`; + ['history-delete-selected', 'history-bulk-status', 'history-print-labels', 'history-publish-selected', 'history-edit-selected', 'history-price-selected', 'history-duplicate-selected'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + const clear = document.getElementById('history-clear-selection'); + clear?.classList.toggle('hidden', count === 0); + document.getElementById('history-batch-bar')?.classList.toggle('has-selection', count > 0); + const selectAll = document.getElementById('history-select-all'); + if (selectAll) { + const selectedOnPage = historyState.listings.filter(listing => historyState.selected.has(listing.id)).length; + selectAll.checked = historyState.listings.length > 0 && selectedOnPage === historyState.listings.length; + selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < historyState.listings.length; + } +} + +let detailRenderSequence = 0; +let detailPhotoDragIndex = -1; + +function detailPlatformOptions(selected) { + return state.platforms.map(platform => ``).join(''); +} + +function detailProviderOptions(selected) { + return state.providers.map(provider => ``).join(''); +} + +function detailLanguageOptions(selected) { + const languages = [['de', 'Deutsch'], ['en', 'English'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['es', 'Español'], ['it', 'Italiano'], ['pt', 'Português']]; + return languages.map(([value, label]) => ``).join(''); +} + +function getDetailListingForHtml() { + if (!detailEditorState) return null; + const tags = (document.getElementById('detail-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform, + title: document.getElementById('detail-title')?.value || '', + description: editors.detail ? editors.detail.getText().trim() : '', + description_html: getEditorHtml(editors.detail), + tags, + brand: document.getElementById('detail-brand')?.value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size')?.value || null, + color: document.getElementById('detail-color')?.value || null, + condition: document.getElementById('detail-condition')?.value || null, + }; +} + +const refreshDetailPlatformPreview = debounce(async () => { + const listing = getDetailListingForHtml(); + if (!listing) return; + const sequence = ++detailRenderSequence; + setHtmlSyncStatus('detail-html-sync-status', 'Wird aktualisiert …', 'working'); + try { + const data = await api('POST', '/api/html-render', { platform: listing.platform, listing }); + if (sequence !== detailRenderSequence) return; + detailEditorState.platformHtml = data.html || listing.description_html; + setSandboxPreview(document.getElementById('detail-html-preview'), detailEditorState.platformHtml); + setHtmlSyncStatus('detail-html-sync-status', 'Synchron', 'ok'); + } catch { + if (sequence !== detailRenderSequence) return; + setSandboxPreview(document.getElementById('detail-html-preview'), listing.description_html); + setHtmlSyncStatus('detail-html-sync-status', 'Vorschau lokal', 'warning'); + } +}, 140); + +function setupDetailHtmlSync() { + const editor = editors.detail; + const source = document.getElementById('detail-html-source'); + if (!editor || !source) return; + + editor.on('text-change', () => { + if (htmlSyncState.detailLock) return; + source.value = getEditorHtml(editor); + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }); + + const applySource = debounce(() => { + htmlSyncState.detailLock = true; + const safe = sanitizeRichHtmlClient(source.value); + setEditorHtml(editor, safe); + htmlSyncState.detailLock = false; + source.value = safe; + const count = document.getElementById('detail-description-count'); + if (count) count.textContent = `${editor.getText().trim().length} Zeichen`; + refreshDetailPlatformPreview(); + }, 100); + + source.addEventListener('input', () => { + setHtmlSyncStatus('detail-html-sync-status', 'Code wird übernommen …', 'working'); + setSandboxPreview(document.getElementById('detail-html-preview'), sanitizeRichHtmlClient(source.value)); + applySource(); + }); +} + +function renderDetailPhotos() { + if (!detailEditorState) return; + const photos = detailEditorState.photos; + const hero = document.getElementById('detail-photo-hero'); + const empty = document.getElementById('detail-photo-empty'); + const strip = document.getElementById('detail-photo-strip'); + const count = document.getElementById('detail-photo-count'); + const activeIndex = Math.min(detailEditorState.activePhotoIndex || 0, Math.max(0, photos.length - 1)); + detailEditorState.activePhotoIndex = activeIndex; + if (count) count.textContent = `${photos.length} / 10`; + if (hero && empty) { + if (photos.length) { + hero.src = photoUrl(photos[activeIndex]); + hero.alt = `Produktfoto ${activeIndex + 1}`; + hero.classList.remove('hidden'); + empty.classList.add('hidden'); + } else { + hero.removeAttribute('src'); + hero.classList.add('hidden'); + empty.classList.remove('hidden'); + } + } + ['detail-edit-photo', 'detail-set-cover', 'detail-remove-photo'].forEach(id => { + const button = document.getElementById(id); + if (button) button.disabled = photos.length === 0; + }); + if (!strip) return; + strip.innerHTML = photos.map((photo, index) => ` + `).join(''); + strip.querySelectorAll('[data-detail-photo-index]').forEach(button => { + const index = Number(button.dataset.detailPhotoIndex); + button.addEventListener('click', () => { detailEditorState.activePhotoIndex = index; renderDetailPhotos(); }); + button.addEventListener('dragstart', event => { detailPhotoDragIndex = index; event.dataTransfer.effectAllowed = 'move'; button.classList.add('is-dragging'); }); + button.addEventListener('dragend', () => { detailPhotoDragIndex = -1; button.classList.remove('is-dragging'); }); + button.addEventListener('dragover', event => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }); + button.addEventListener('drop', event => { + event.preventDefault(); + if (detailPhotoDragIndex < 0 || detailPhotoDragIndex === index) return; + const [moved] = detailEditorState.photos.splice(detailPhotoDragIndex, 1); + detailEditorState.photos.splice(index, 0, moved); + detailEditorState.activePhotoIndex = index; + detailPhotoDragIndex = -1; + renderDetailPhotos(); + }); + }); +} + +async function uploadDetailPhotos(files) { + if (!detailEditorState) return; + const available = Math.max(0, 10 - detailEditorState.photos.length); + const accepted = Array.from(files || []).filter(file => file.type.startsWith('image/')).slice(0, available); + if (!available) return toast('Maximal 10 Fotos pro Listing'); + if (!accepted.length) return; + const platform = document.getElementById('detail-platform')?.value || detailEditorState.listing.platform || 'allgemein'; + const formData = new FormData(); + formData.append('platform', platform); + accepted.forEach(file => formData.append('photos', file)); + try { + const response = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, { + method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin', + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen'); + detailEditorState.photos.push(...(data.filenames || [])); + detailEditorState.activePhotoIndex = Math.max(0, detailEditorState.photos.length - (data.filenames || []).length); + renderDetailPhotos(); + toast(`${(data.filenames || []).length} Foto${(data.filenames || []).length === 1 ? '' : 's'} hinzugefügt`); + } catch (err) { toast('Upload fehlgeschlagen: ' + err.message); } +} + +function setupDetailPhotoActions() { + const fileInput = document.getElementById('detail-photo-input'); + document.getElementById('detail-add-photo')?.addEventListener('click', () => fileInput?.click()); + document.getElementById('detail-mobile-photo')?.addEventListener('click', () => openMobileUpload('listing', detailEditorState?.listing?.id)); + fileInput?.addEventListener('change', () => { uploadDetailPhotos(fileInput.files); fileInput.value = ''; }); + document.getElementById('detail-edit-photo')?.addEventListener('click', () => openPhotoEditor(detailEditorState.activePhotoIndex, 'detail')); + document.getElementById('detail-set-cover')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (index <= 0 || !detailEditorState.photos[index]) return; + const [photo] = detailEditorState.photos.splice(index, 1); + detailEditorState.photos.unshift(photo); + detailEditorState.activePhotoIndex = 0; + renderDetailPhotos(); + toast('Coverfoto festgelegt'); + }); + document.getElementById('detail-download-photos')?.addEventListener('click', () => downloadPublishPhotos(detailEditorState?.photos || [])); + document.getElementById('detail-remove-photo')?.addEventListener('click', () => { + const index = detailEditorState.activePhotoIndex; + if (!detailEditorState.photos[index]) return; + detailEditorState.photos.splice(index, 1); + detailEditorState.activePhotoIndex = Math.max(0, Math.min(index, detailEditorState.photos.length - 1)); + renderDetailPhotos(); + }); +} + +async function showDetail(id) { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + toast('Ungültige Listing-ID'); + return; + } + + const historySection = document.getElementById('history'); + if (!historySection?.classList.contains('active')) { + switchToTab('history'); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + const l = await api('GET', `/api/listings/${numericId}`); + const tags = Array.isArray(l.tags) ? l.tags.join(', ') : (l.tags || ''); + const skuVal = l.sku || `VD-${String(l.id).padStart(4, '0')}`; + detailEditorState = { + listing: l, + photos: [...(Array.isArray(l.photos) ? l.photos : [])], + activePhotoIndex: 0, + categoryPicker: null, + platformHtml: '', + aiModelPhotos: [], + aiModelGenerations: [], + }; + + document.getElementById('detail-content').innerHTML = ` +
                                                                      +
                                                                      +
                                                                      + Artikel vollständig bearbeiten +

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

                                                                      +

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

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      02

                                                                      Listing-Daten

                                                                      +
                                                                      + + + + + + +
                                                                      + + +
                                                                      + +
                                                                      +
                                                                      03

                                                                      Beschreibung

                                                                      0 Zeichen
                                                                      +
                                                                      WYSIWYGFormatierte Bearbeitung
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      + Synchron +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      04

                                                                      Eigenschaften & Preis

                                                                      +
                                                                      + + + + + + +
                                                                      + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      `; + + const lockResult = await acquireListingLock(numericId); + if (!userCan('listings.edit') || lockResult.readOnly) { + document.querySelectorAll('#history-detail input, #history-detail textarea, #history-detail select').forEach(element => { element.disabled = true; }); + document.getElementById('detail-save')?.setAttribute('disabled', 'disabled'); + if (!lockResult.lock && !userCan('listings.edit')) showListingLockBanner({ user_name: 'Deine Rolle erlaubt nur Lesezugriff' }, false); + } + + editors.detail = initQuill('#detail-description-editor', 'Beschreibung...'); + const detailReadOnly = !userCan('listings.edit') || lockResult.readOnly; + if (detailReadOnly) editors.detail?.enable?.(false); + const initialHtml = l.description_html ? sanitizeRichHtmlClient(l.description_html) : plainTextToRichHtml(l.description || ''); + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, initialHtml); + htmlSyncState.detailLock = false; + document.getElementById('detail-html-source').value = getEditorHtml(editors.detail); + document.getElementById('detail-description-count').textContent = `${editors.detail ? editors.detail.getText().trim().length : 0} Zeichen`; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', l.platform, l.category || null); + setupDetailHtmlSync(); + bindHtmlViewTabs('detail'); + setupDetailPhotoActions(); + if (detailReadOnly) { + const mutationButtons = [ + 'detail-save', 'detail-delete', 'detail-add-photo', 'detail-mobile-photo', + 'detail-edit-photo', 'detail-set-cover', 'detail-remove-photo', + 'detail-ai-model-prompt-btn', 'detail-ai-model-btn', 'detail-clean-html', + ]; + mutationButtons.forEach(id => { + const element = document.getElementById(id); + if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); } + }); + } + document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos); + document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail')); + document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail')); + updateAiModeControls('detail'); + renderDetailPhotos(); + renderAiModelGrid('detail', []); + addQuillTooltips(); + refreshDetailPlatformPreview(); + loadDetailAiModelPhotos(numericId); + loadDetailAiModelGenerations(numericId); + + if (typeof QRious !== 'undefined') { + new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' }); + } + + const refreshFields = ['detail-title', 'detail-tags', 'detail-brand', 'detail-size', 'detail-color', 'detail-condition']; + refreshFields.forEach(fieldId => document.getElementById(fieldId)?.addEventListener('input', refreshDetailPlatformPreview)); + document.getElementById('detail-title')?.addEventListener('input', event => { + document.getElementById('detail-title-count').textContent = `${event.target.value.length} / 120`; + }); + document.getElementById('detail-platform')?.addEventListener('change', event => { + const currentCategory = detailEditorState.categoryPicker?.getValue?.() || null; + detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', event.target.value, currentCategory); + refreshDetailPlatformPreview(); + }); + document.getElementById('detail-sku')?.addEventListener('input', event => { document.getElementById('detail-sku-display').textContent = event.target.value || 'Ohne SKU'; }); + + document.getElementById('detail-copy-editor-html').addEventListener('click', () => { navigator.clipboard.writeText(getEditorHtml(editors.detail)); toast('Editor-HTML kopiert'); }); + document.getElementById('detail-copy-platform-html').addEventListener('click', () => { navigator.clipboard.writeText(detailEditorState.platformHtml || getEditorHtml(editors.detail)); toast('Plattform-HTML kopiert'); }); + document.getElementById('detail-clean-html').addEventListener('click', () => { + const source = document.getElementById('detail-html-source'); + const safe = sanitizeRichHtmlClient(source.value); + source.value = safe; + htmlSyncState.detailLock = true; + setEditorHtml(editors.detail, safe); + htmlSyncState.detailLock = false; + refreshDetailPlatformPreview(); + toast('HTML bereinigt'); + }); + + document.getElementById('detail-print-label').addEventListener('click', () => printLabels([{ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, price: parseFloat(document.getElementById('detail-price').value) || null }])); + document.getElementById('detail-print-sheet').addEventListener('click', () => printDatasheet({ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, description: editors.detail?.getText().trim() || '', price: parseFloat(document.getElementById('detail-price').value) || null })); + + document.getElementById('detail-save').addEventListener('click', async () => { + const descriptionHtml = getEditorHtml(editors.detail); + const update = { + platform: document.getElementById('detail-platform').value, + ai_provider: document.getElementById('detail-ai-provider').value, + language: document.getElementById('detail-language').value, + title: document.getElementById('detail-title').value, + description: richHtmlToText(descriptionHtml), + description_html: descriptionHtml, + tags: document.getElementById('detail-tags').value.split(',').map(tag => tag.trim()).filter(Boolean), + photos: [...detailEditorState.photos], + seller_notes: document.getElementById('detail-seller-notes').value || null, + status: document.getElementById('detail-status').value, + sku: document.getElementById('detail-sku').value || null, + brand: document.getElementById('detail-brand').value || null, + category: detailEditorState.categoryPicker?.getValue?.() || null, + size: document.getElementById('detail-size').value || null, + color: document.getElementById('detail-color').value || null, + condition: document.getElementById('detail-condition').value || null, + price: parseFloat(document.getElementById('detail-price').value) || null, + storage_location: document.getElementById('detail-storage-location').value || null, + }; + try { + const saved = await api('PUT', `/api/listings/${numericId}`, { ...update, _edit_lock: listingEditLock.listingId === numericId ? listingEditLock.token : null }); + detailEditorState.listing = saved; + toast('Alle Änderungen gespeichert'); + loadHistory(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('detail-copy-all').addEventListener('click', () => { + const content = `${document.getElementById('detail-title').value}\n\n${editors.detail?.getText().trim() || ''}\n\n${document.getElementById('detail-tags').value}`; + navigator.clipboard.writeText(content); + toast('Alles kopiert!'); + }); + + document.getElementById('detail-delete').addEventListener('click', async () => { + if (!confirm('Listing in den Papierkorb verschieben?')) return; + await api('DELETE', `/api/listings/${numericId}`); + toast('In Papierkorb verschoben'); + updateTrashBadge(); + document.getElementById('back-to-list').click(); + loadHistory(); + }); + + setHistoryDetailMode(true); + document.getElementById('history-detail')?.scrollIntoView({ block: 'start' }); +} + +// --- Templates --- +function setupTemplates() { + document.getElementById('add-template-btn').addEventListener('click', () => { + state.editingTemplateId = null; + document.getElementById('template-form-title').textContent = 'Neue Vorlage'; + document.getElementById('tpl-name').value = ''; + document.getElementById('tpl-platform').value = ''; + document.getElementById('tpl-language').value = 'de'; + if (editors.tplNotes) editors.tplNotes.setText(''); + document.getElementById('tpl-tags').value = ''; + document.getElementById('template-form').classList.remove('hidden'); + }); + document.getElementById('cancel-template-btn').addEventListener('click', () => { + document.getElementById('template-form').classList.add('hidden'); + }); + document.getElementById('save-template-btn').addEventListener('click', async () => { + const data = { + name: document.getElementById('tpl-name').value, + platform: document.getElementById('tpl-platform').value || null, + language: document.getElementById('tpl-language').value, + seller_notes: editors.tplNotes ? editors.tplNotes.getText().trim() : '', + default_tags: document.getElementById('tpl-tags').value.split(',').map(t => t.trim()).filter(Boolean), + }; + if (!data.name) { toast('Name ist Pflichtfeld'); return; } + try { + if (state.editingTemplateId) { + await api('PUT', `/api/templates/${state.editingTemplateId}`, data); + } else { + await api('POST', '/api/templates', data); + } + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + document.getElementById('template-form').classList.add('hidden'); + toast('Vorlage gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); +} + +function renderTemplateList() { + const c = document.getElementById('template-list'); + if (!state.templates.length) { c.innerHTML = '
                                                                      Keine Vorlagen vorhanden
                                                                      '; return; } + c.innerHTML = state.templates.map(t => { + const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen'; + return `
                                                                      +
                                                                      +
                                                                      ${escapeHtml(t.name)}
                                                                      +
                                                                      ${pName} · ${t.language.toUpperCase()}
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      `; + }).join(''); + + c.querySelectorAll('.tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const t = state.templates.find(x => x.id === parseInt(btn.dataset.id)); + if (!t) return; + state.editingTemplateId = t.id; + document.getElementById('template-form-title').textContent = 'Vorlage bearbeiten'; + document.getElementById('tpl-name').value = t.name; + document.getElementById('tpl-platform').value = t.platform || ''; + document.getElementById('tpl-language').value = t.language || 'de'; + if (editors.tplNotes) editors.tplNotes.setText(t.seller_notes || ''); + document.getElementById('tpl-tags').value = (t.default_tags || []).join(', '); + document.getElementById('template-form').classList.remove('hidden'); + }); + }); + c.querySelectorAll('.tpl-delete').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Vorlage löschen?')) return; + await api('DELETE', `/api/templates/${btn.dataset.id}`); + state.templates = await api('GET', '/api/templates'); + renderTemplateList(); + updateTemplateDropdown(); + toast('Vorlage gelöscht'); + }); + }); +} + +function updateTemplateDropdown() { + const sel = document.getElementById('template-select'); + sel.innerHTML = '' + + state.templates.map(t => ``).join(''); +} + +function populateTemplateSelects(platforms) { + const sel = document.getElementById('tpl-platform'); + sel.innerHTML = '' + + platforms.map(p => ``).join(''); +} + +// --- Inventory / Storage --- +async function loadInventory() { + const locations = await api('GET', '/api/inventory/locations'); + const unassigned = await api('GET', '/api/inventory/unassigned'); + + const statsEl = document.getElementById('inv-stats'); + const totalItems = locations.reduce((s, l) => s + l.count, 0) + unassigned.length; + const totalActive = locations.reduce((s, l) => s + l.active, 0) + unassigned.filter(x => x.status === 'active').length; + const totalValue = locations.reduce((s, l) => s + l.total_value, 0) + unassigned.filter(x => x.status === 'active').reduce((s, x) => s + (x.price || 0), 0); + statsEl.innerHTML = ` +
                                                                      ${totalItems}
                                                                      Artikel gesamt
                                                                      +
                                                                      ${locations.length}
                                                                      Lagerorte
                                                                      +
                                                                      ${totalActive}
                                                                      Aktive Artikel
                                                                      +
                                                                      ${totalValue.toFixed(0)} €
                                                                      Lagerwert
                                                                      `; + + const locsEl = document.getElementById('inv-locations'); + let html = ''; + for (const loc of locations) { + html += `
                                                                      +
                                                                      +
                                                                      +
                                                                      ${escapeHtml(loc.storage_location)}
                                                                      +
                                                                      +
                                                                      + ${loc.count} Artikel + ${loc.active} aktiv + ${loc.sold ? `${loc.sold} verkauft` : ''} + ${loc.reserved ? `${loc.reserved} reserviert` : ''} +
                                                                      +
                                                                      ${loc.total_value.toFixed(0)} €
                                                                      +
                                                                      `; + } + if (unassigned.length) { + html += `
                                                                      +
                                                                      +
                                                                      +
                                                                      Ohne Lagerort
                                                                      +
                                                                      +
                                                                      ${unassigned.length} Artikel
                                                                      +
                                                                      `; + } + if (!html) html = '
                                                                      Noch keine Lagerorte vorhanden. Weise Artikeln einen Lagerort zu.
                                                                      '; + locsEl.innerHTML = html; + + document.getElementById('inv-detail').classList.add('hidden'); + locsEl.classList.remove('hidden'); + + locsEl.querySelectorAll('.inv-location-card').forEach(card => { + card.addEventListener('click', () => showInventoryDetail(card.dataset.location)); + }); + + document.getElementById('inv-add-location-btn').onclick = () => { + const name = prompt('Neuer Lagerort (z.B. Regal A1, Karton 5, Schrank oben):'); + if (!name?.trim()) return; + toast(`Lagerort "${name.trim()}" angelegt. Weise jetzt Artikel zu.`); + loadInventory(); + }; +} + +async function showInventoryDetail(location) { + const items = location + ? await api('GET', `/api/inventory/location/${encodeURIComponent(location)}`) + : await api('GET', '/api/inventory/unassigned'); + + document.getElementById('inv-locations').classList.add('hidden'); + const detail = document.getElementById('inv-detail'); + detail.classList.remove('hidden'); + document.getElementById('inv-detail-title').textContent = location || 'Ohne Lagerort'; + + const list = document.getElementById('inv-detail-list'); + if (!items.length) { + list.innerHTML = '
                                                                      Keine Artikel an diesem Lagerort
                                                                      '; + } else { + list.innerHTML = items.map(l => { + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const statusCls = l.status === 'sold' ? 'sold' : l.status === 'reserved' ? 'reserved' : ''; + return `
                                                                      + +
                                                                      +
                                                                      ${l.sku ? `${escapeHtml(l.sku)}` : ''} ${escapeHtml(l.title || 'Ohne Titel')}
                                                                      +
                                                                      ${pName} · ${l.price ? l.price.toFixed(2) + ' €' : '–'} · ${l.status || 'active'}
                                                                      +
                                                                      +
                                                                      `; + }).join(''); + } + + document.getElementById('inv-back').onclick = () => loadInventory(); + + document.getElementById('inv-rename-btn').onclick = async () => { + if (!location) return toast('Ohne Lagerort kann nicht umbenannt werden'); + const newName = prompt('Lagerort umbenennen:', location); + if (!newName?.trim() || newName.trim() === location) return; + const ids = items.map(l => l.id); + await api('PUT', '/api/inventory/bulk-location', { ids, location: newName.trim() }); + toast(`Umbenannt zu "${newName.trim()}"`); + loadInventory(); + }; + + document.getElementById('inv-move-btn').onclick = async () => { + const checked = [...detail.querySelectorAll('.inv-item-check:checked')].map(c => parseInt(c.dataset.id)); + if (!checked.length) return toast('Bitte Artikel auswählen'); + const dest = prompt('Ziel-Lagerort:'); + if (!dest?.trim()) return; + await api('PUT', '/api/inventory/bulk-location', { ids: checked, location: dest.trim() }); + toast(`${checked.length} Artikel verschoben nach "${dest.trim()}"`); + loadInventory(); + }; +} + +// --- Fee Calculator --- +function setupFeeCalculator() { + document.getElementById('fee-price').addEventListener('input', debounce(updateFeeCalculator, 200)); +} + +async function updateFeeCalculator() { + const price = parseFloat(document.getElementById('fee-price').value) || 0; + if (price <= 0) { document.getElementById('fee-results').innerHTML = ''; return; } + try { + const fees = await api('GET', `/api/fees?price=${price}`); + const c = document.getElementById('fee-results'); + c.innerHTML = Object.entries(fees).map(([id, f]) => { + const name = state.platforms.find(p => p.id === id)?.name || id; + return `
                                                                      +
                                                                      +
                                                                      ${escapeHtml(name)} ${f.seller_type === 'privat' ? 'Privat' : 'Shop'}
                                                                      +
                                                                      ${escapeHtml(f.breakdown || '')}
                                                                      + ${f.note ? `
                                                                      ${escapeHtml(f.note)}
                                                                      ` : ''} +
                                                                      +
                                                                      +
                                                                      ${Number(f.net || 0).toFixed(2)} €
                                                                      +
                                                                      ${Number(f.fees) === 0 ? 'Keine Verkäufergebühr' : `-${Number(f.fees).toFixed(2)} € Gebühren`}
                                                                      +
                                                                      +
                                                                      `; + }).join(''); + } catch {} +} + +// --- Publish Center --- +const publishState = { + selected: new Set(), + listings: [], + allFiltered: [], + publishStatus: {}, + queue: [], + scheduled: [], + workflow: null, + integrations: { ebay: null, etsy: null }, + selectedId: null, + view: 'smart', + renderToken: 0, + smartCopyToken: 0, +}; + +function setupPublish() { + const search = document.getElementById('publish-search'); + if (!search) return; + + search.addEventListener('input', debounce(loadPublishList, 250)); + document.getElementById('publish-filter-platform')?.addEventListener('change', loadPublishList); + document.getElementById('publish-filter-status')?.addEventListener('change', loadPublishList); + document.getElementById('publish-sort')?.addEventListener('change', () => { + sortPublishListings(); + renderPublishList(); + }); + document.getElementById('publish-select-all')?.addEventListener('change', event => { + for (const listing of publishState.listings) { + if (event.target.checked) publishState.selected.add(listing.id); + else publishState.selected.delete(listing.id); + } + renderPublishList(); + updatePublishToolbar(); + }); + + const platformSelect = document.getElementById('publish-filter-platform'); + platformSelect.innerHTML = '' + + state.platforms.map(platform => ``).join(''); + + document.querySelectorAll('[data-publish-view]').forEach(button => { + button.addEventListener('click', () => { + setPublishView(button.dataset.publishView); + if (button.dataset.publishView === 'smart' && selectedPublishListing()) openSmartCopyModal(selectedPublishListing()); + }); + }); + document.querySelectorAll('[data-status-target]').forEach(button => { + button.addEventListener('click', () => setPublishView(button.dataset.statusTarget)); + }); + + document.getElementById('publish-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-status-refresh')?.addEventListener('click', loadPublishList); + document.getElementById('publish-batch-copy')?.addEventListener('click', batchSmartCopy); + document.getElementById('publish-batch-etsy')?.addEventListener('click', batchEtsyPublish); + document.getElementById('publish-batch-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-add-selected-ebay')?.addEventListener('click', batchEbayAdd); + document.getElementById('publish-batch-schedule')?.addEventListener('click', openBatchSchedule); + document.getElementById('publish-download-photos')?.addEventListener('click', batchDownloadPhotos); + document.getElementById('publish-ebay-queue-btn')?.addEventListener('click', openEbayQueue); + document.getElementById('publish-schedule-overview')?.addEventListener('click', openScheduleOverview); + document.getElementById('publish-ebay-queue-process')?.addEventListener('click', processEbayQueue); + document.getElementById('publish-ebay-queue-retry')?.addEventListener('click', retryEbayQueue); + + document.getElementById('schedule-modal-close')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-cancel')?.addEventListener('click', closePublishSchedule); + document.getElementById('schedule-modal')?.addEventListener('click', event => { + if (event.target === event.currentTarget) closePublishSchedule(); + }); + document.querySelectorAll('[data-smart-copy-close]').forEach(button => button.addEventListener('click', closeSmartCopyModal)); + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + if (isSmartCopyModalOpen()) closeSmartCopyModal(); + if (!document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) closeFluxPromptLab(); + }); +} + +async function publishSafeGet(url, fallback, attempts = 1) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt++) { + try { return await api('GET', url); } + catch (error) { + lastError = error; + if (attempt < attempts - 1) await new Promise(resolve => setTimeout(resolve, 220 * (attempt + 1))); + } + } + if (fallback === PUBLISH_REQUIRED) throw lastError || new Error('Daten konnten nicht geladen werden'); + return fallback; +} + +const PUBLISH_REQUIRED = Symbol('publish-required'); + +async function loadPublishListLegacy() { + const search = document.getElementById('publish-search'); + const listContainer = document.getElementById('publish-list'); + if (!search || !listContainer) return; + const renderToken = ++publishState.renderToken; + + const q = search.value.trim(); + const platform = document.getElementById('publish-filter-platform')?.value || ''; + const status = document.getElementById('publish-filter-status')?.value || ''; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (platform) params.set('platform', platform); + if (status) params.set('status', status); + + const refreshButtons = [document.getElementById('publish-refresh'), document.getElementById('publish-status-refresh')]; + const refreshLabels = refreshButtons.map(button => button?.textContent || ''); + refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.textContent = index === 0 ? 'Wird geladen …' : '…'; }); + if (!publishState.listings.length) listContainer.innerHTML = '
                                                                      Artikel werden geladen…
                                                                      '; + + try { + const query = params.toString(); + const [listings, queue, scheduled, workflow, ebayStatus, etsyStatus] = await Promise.all([ + publishSafeGet(`/api/listings${query ? `?${query}` : ''}`, PUBLISH_REQUIRED, 3), + publishSafeGet('/api/ebay/queue', [], 2), + publishSafeGet('/api/schedule', [], 2), + publishSafeGet('/api/dashboard/workflow?limit=4', null, 2), + publishSafeGet('/api/ebay/status', { connected: false, hasCredentials: false }, 2), + publishSafeGet('/api/etsy/status', { connected: false, hasApiKey: false }, 2), + ]); + if (renderToken !== publishState.renderToken) return; + + publishState.allFiltered = Array.isArray(listings) ? listings : []; + publishState.listings = [...publishState.allFiltered]; + publishState.queue = Array.isArray(queue) ? queue : []; + publishState.scheduled = Array.isArray(scheduled) ? scheduled : []; + publishState.workflow = workflow; + publishState.integrations = { ebay: ebayStatus, etsy: etsyStatus }; + + const ids = publishState.listings.map(listing => listing.id); + publishState.publishStatus = ids.length + ? await publishSafeGet(`/api/publish/status?ids=${ids.join(',')}`, {}, 2) + : {}; + if (renderToken !== publishState.renderToken) return; + + sortPublishListings(); + const visibleIds = new Set(publishState.listings.map(listing => listing.id)); + if (!publishState.selectedId || !visibleIds.has(publishState.selectedId)) { + const selectedVisible = [...publishState.selected].find(id => visibleIds.has(id)); + publishState.selectedId = selectedVisible || publishState.listings[0]?.id || null; + } + + renderPublishList(); + renderPublishWorkspace(); + renderPublishStatusRail(); + updatePublishToolbar(); + } catch (error) { + if (renderToken !== publishState.renderToken) return; + toast(`Publish-Center konnte nicht geladen werden: ${error.message}`); + listContainer.innerHTML = `
                                                                      Artikel konnten nicht geladen werden${escapeHtml(error.message)}
                                                                      `; + listContainer.querySelector('[data-publish-retry]')?.addEventListener('click', loadPublishList); + } finally { + if (renderToken === publishState.renderToken) refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = false; button.removeAttribute('aria-busy'); button.textContent = refreshLabels[index]; }); + } +} + +function sortPublishListings() { + const sort = document.getElementById('publish-sort')?.value || 'newest'; + const byDate = value => new Date(value || 0).getTime() || 0; + publishState.listings.sort((a, b) => { + if (sort === 'oldest') return byDate(a.created_at) - byDate(b.created_at); + if (sort === 'price-desc') return (Number(b.price) || 0) - (Number(a.price) || 0); + if (sort === 'price-asc') return (Number(a.price) || 0) - (Number(b.price) || 0); + if (sort === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'de'); + return byDate(b.created_at) - byDate(a.created_at); + }); +} + +function publishLatestStatus(listing) { + const statuses = publishState.publishStatus[listing.id] || {}; + const entries = Object.entries(statuses); + if (!entries.length) return { label: 'Vorbereitet', status: 'prepared', platform: listing.platform }; + const priority = { failed: 0, uploading: 1, pending: 2, copied: 3, published: 4 }; + entries.sort((a, b) => (priority[a[1]] ?? 9) - (priority[b[1]] ?? 9)); + const [platform, status] = entries[0]; + const labels = { failed: 'Fehlgeschlagen', uploading: 'In Bearbeitung', pending: 'Ausstehend', copied: 'Kopiert', published: 'Live' }; + return { label: labels[status] || status, status, platform }; +} + +function renderPublishList() { + const container = document.getElementById('publish-list'); + if (!container) return; + document.getElementById('publish-list-count').textContent = publishState.listings.length; + + if (!publishState.listings.length) { + container.innerHTML = '
                                                                      Keine Artikel gefundenÄndere Suche oder Filter.
                                                                      '; + updatePublishToolbar(); + return; + } + + container.innerHTML = publishState.listings.map(listing => { + const photo = listing.photos?.[0]; + const status = publishLatestStatus(listing); + const selected = listing.id === publishState.selectedId; + const checked = publishState.selected.has(listing.id); + return `
                                                                      + +
                                                                      ${photo ? `` : 'Kein Bild'}
                                                                      +
                                                                      + ${escapeHtml(listing.title || 'Ohne Titel')} + ${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} +
                                                                      ${escapeHtml(platformLabel(listing.platform))}${formatCurrency(listing.price) || 'Preis fehlt'}
                                                                      +
                                                                      + ${escapeHtml(status.label)} +
                                                                      `; + }).join(''); + + container.querySelectorAll('[data-publish-listing-id]').forEach(card => { + card.addEventListener('click', event => { + if (event.target.closest('input,button,a,label')) return; + publishState.selectedId = Number(card.dataset.publishListingId); + renderPublishList(); + renderPublishWorkspace(); + }); + }); + + container.querySelectorAll('[data-publish-check]').forEach(checkbox => { + checkbox.addEventListener('change', event => { + const id = Number(event.target.dataset.publishCheck); + if (event.target.checked) publishState.selected.add(id); + else publishState.selected.delete(id); + if (!publishState.selectedId) publishState.selectedId = id; + updatePublishToolbar(); + }); + }); +} + +function updatePublishToolbar() { + const count = publishState.selected.size; + const selectedLabel = document.getElementById('publish-selected-count'); + if (selectedLabel) selectedLabel.textContent = `${count} ausgewählt`; + ['publish-batch-copy', 'publish-batch-etsy', 'publish-batch-ebay', 'publish-batch-schedule', 'publish-download-photos', 'publish-add-selected-ebay'] + .forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; }); + + const visible = publishState.listings; + const selectedVisible = visible.filter(listing => publishState.selected.has(listing.id)).length; + const selectAll = document.getElementById('publish-select-all'); + if (selectAll) { + selectAll.checked = visible.length > 0 && selectedVisible === visible.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length; + } +} + +function setPublishViewLegacy(view) { + publishState.view = ['smart', 'direct', 'queue', 'scheduled'].includes(view) ? view : 'smart'; + document.querySelectorAll('[data-publish-view]').forEach(button => { + const active = button.dataset.publishView === publishState.view; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + renderPublishWorkspace(); +} + +function selectedPublishListing() { + return publishState.listings.find(listing => listing.id === publishState.selectedId) + || publishState.allFiltered.find(listing => listing.id === publishState.selectedId) + || null; +} + +function renderPublishWorkspaceLegacy() { + if (publishState.view === 'queue') { renderQueueWorkspace(); return; } + if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; } + + const listing = selectedPublishListing(); + const workspace = document.getElementById('publish-workspace'); + if (!workspace) return; + if (!listing) { + workspace.innerHTML = `

                                                                      Artikel auswählen

                                                                      Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.

                                                                      `; + return; + } + if (publishState.view === 'direct') renderDirectPublishWorkspace(listing); + else renderSmartCopyLauncher(listing); +} + + +function renderSmartCopyLauncher(listing) { + const workspace = document.getElementById('publish-workspace'); + const target = publishPlatformTarget(listing.platform); + const score = publishReadinessScore(listing); + const tags = Array.isArray(listing.tags) ? listing.tags : String(listing.tags || '').split(',').map(value => value.trim()).filter(Boolean); + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit')} +
                                                                      +
                                                                      +
                                                                      Verkaufsdaten

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

                                                                      +
                                                                      ${escapeHtml(listing.description || 'Noch keine Beschreibung vorhanden.').replace(/\n/g, '
                                                                      ')}
                                                                      +
                                                                      ${tags.length ? tags.slice(0, 12).map(tag => `${escapeHtml(tag)}`).join('') : 'Keine Tags'}
                                                                      +
                                                                      +
                                                                      Plattform${escapeHtml(target.name)}
                                                                      Preis${escapeHtml(formatCurrency(listing.price) || 'Nicht gesetzt')}
                                                                      +
                                                                      Zustand${escapeHtml(listing.condition || 'Nicht gesetzt')}
                                                                      Marke${escapeHtml(listing.brand || 'Nicht gesetzt')}
                                                                      +
                                                                      Kategorie${escapeHtml(listing.category || 'Nicht gesetzt')}
                                                                      Größe${escapeHtml(listing.size || 'Nicht gesetzt')}
                                                                      +
                                                                      +
                                                                      + +
                                                                      `; + bindPublishWorkspaceNavigation(workspace); + workspace.querySelector('[data-open-smart-copy-modal]')?.addEventListener('click', () => openSmartCopyModal(listing)); + window.VendooIcons?.render(workspace); +} + +function isSmartCopyModalOpen() { + return !document.getElementById('smart-copy-modal')?.classList.contains('hidden'); +} + +function closeSmartCopyModal() { + const modal = document.getElementById('smart-copy-modal'); + modal?.classList.add('hidden'); + modal?.setAttribute('aria-hidden', 'true'); + document.getElementById('smart-copy-modal-content')?.replaceChildren(); + if (document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) document.body.classList.remove('modal-open'); +} + +function openSmartCopyModal(listing = selectedPublishListing()) { + if (!listing) return toast('Bitte zuerst ein Artikel auswählen.'); + const modal = document.getElementById('smart-copy-modal'); + modal?.classList.remove('hidden'); + modal?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + renderSmartCopyWorkspace(listing); +} + +function publishWorkspaceHeader(listing, kicker, stateLabel) { + const photo = listing.photos?.[0]; + return `
                                                                      +
                                                                      +
                                                                      ${photo ? `` : 'Kein Bild'}
                                                                      +
                                                                      ${escapeHtml(kicker)}

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

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

                                                                      +
                                                                      +
                                                                      + ${escapeHtml(stateLabel)} + + +
                                                                      +
                                                                      `; +} + +function bindPublishWorkspaceNavigation(scope = document) { + const currentIndex = publishState.listings.findIndex(listing => listing.id === publishState.selectedId); + scope.querySelector('[data-publish-previous]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex <= 0 ? publishState.listings.length - 1 : currentIndex - 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); + if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace(); + }); + scope.querySelector('[data-publish-next]')?.addEventListener('click', () => { + if (!publishState.listings.length) return; + const nextIndex = currentIndex >= publishState.listings.length - 1 ? 0 : currentIndex + 1; + publishState.selectedId = publishState.listings[nextIndex].id; + renderPublishList(); + if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace(); + }); +} + +function publishPlatformTarget(platform) { + return ({ + vinted: { name: 'Vinted', url: 'https://www.vinted.de/items/new', direct: false }, + 'ebay-ka': { name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', direct: false }, + 'ebay-de': { name: 'eBay.de', url: 'https://www.ebay.de/sell/create', direct: true }, + etsy: { name: 'Etsy', url: 'https://www.etsy.com/your/shops/me/tools/listings/create', direct: true }, + })[platform] || { name: platformLabel(platform), url: null, direct: false }; +} + +function publishPhotoGrid(photos, limit = 6) { + if (!photos?.length) return '
                                                                      Keine Fotos hinterlegt
                                                                      '; + const visible = photos.slice(0, limit); + return `
                                                                      ${visible.map(photo => ``).join('')}${photos.length > limit ? `+${photos.length - limit} weitere` : ''}
                                                                      `; +} + +async function renderSmartCopyWorkspace(listing) { + const workspace = document.getElementById('smart-copy-modal-content'); + if (!workspace) return; + const token = ++publishState.smartCopyToken; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Wird vorbereitet')}
                                                                      Daten werden vorbereitet …
                                                                      `; + bindPublishWorkspaceNavigation(workspace); + + try { + const data = await api('POST', '/api/publish/smart-copy', { listing_id: listing.id }); + if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return; + const fields = data.fields || {}; + const target = publishPlatformTarget(listing.platform); + const tags = Array.isArray(fields.tags) ? fields.tags.join(', ') : (fields.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit zum Kopieren')} +
                                                                      +
                                                                      +
                                                                      +
                                                                      ${String(fields.title || '').length} Zeichen
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      ${tags ? tags.split(',').filter(Boolean).length : 0}
                                                                      +
                                                                      +
                                                                      +
                                                                      + ${publishReadonlyMeta('Preis', fields.price ? formatCurrency(fields.price) : 'Nicht gesetzt', 'publish-smart-price')} + ${publishReadonlyMeta('Marke', fields.brand || '—', 'publish-smart-brand')} + ${publishReadonlyMeta('Kategorie', fields.category || '—', 'publish-smart-category')} + ${publishReadonlyMeta('Größe', fields.size || '—', 'publish-smart-size')} + ${publishReadonlyMeta('Zustand', fields.condition || '—', 'publish-smart-condition')} +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      ${target.url ? `` : ''}
                                                                      +
                                                                      `; + + bindPublishWorkspaceNavigation(workspace); + bindSmartCopyActions(workspace, listing, data, target, fields, tags); + } catch (error) { + if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return; + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}
                                                                      Smart Copy konnte nicht vorbereitet werden

                                                                      ${escapeHtml(error.message)}

                                                                      `; + bindPublishWorkspaceNavigation(workspace); + workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing)); + } +} + +function publishReadonlyMeta(label, value, id) { + return ``; +} + +function bindSmartCopyActions(scope, listing, data, target, fields, tags) { + scope.querySelectorAll('[data-copy-publish-field]').forEach(button => { + button.addEventListener('click', () => copyPublishValue(document.getElementById(button.dataset.copyPublishField)?.value || '', button)); + }); + scope.querySelector('[data-copy-publish-description]')?.addEventListener('click', event => copyPublishValue(fields.description || '', event.currentTarget)); + scope.querySelector('[data-copy-publish-html]')?.addEventListener('click', async event => { + try { + const html = await api('POST', '/api/html-wrap', { listing_id: listing.id, platform: listing.platform }); + copyPublishValue(html.html || fields.description || '', event.currentTarget); + } catch (error) { toast(`HTML konnte nicht erstellt werden: ${error.message}`); } + }); + scope.querySelector('[data-copy-publish-all]')?.addEventListener('click', event => copyPublishValue(`${fields.title || ''}\n\n${fields.description || ''}\n\n${tags}`, event.currentTarget)); + scope.querySelector('[data-open-publish-platform]')?.addEventListener('click', () => { if (target.url) window.open(target.url, '_blank', 'noopener'); }); + scope.querySelector('[data-mark-publish-live]')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + await api('POST', '/api/publish/mark', { listing_id: listing.id, platform: listing.platform, status: 'published' }); + toast('Artikel als veröffentlicht markiert'); + closeSmartCopyModal(); + await loadPublishList(); + } catch (error) { toast(`Fehler: ${error.message}`); } + finally { button.disabled = false; } + }); + scope.querySelector('[data-download-smart-photos]')?.addEventListener('click', () => downloadPublishPhotos(data.photos || [])); + scope.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); }); +} + +function copyPublishValue(value, button) { + navigator.clipboard.writeText(String(value || '')).then(() => { + const old = button?.textContent; + if (button) button.textContent = 'Kopiert'; + toast('In die Zwischenablage kopiert'); + if (button) setTimeout(() => { button.textContent = old; }, 1200); + }).catch(() => toast('Kopieren nicht möglich')); +} + +function publishReadinessItems(listing) { + return [ + { label: 'Titel', ok: Boolean(listing.title?.trim()) }, + { label: 'Beschreibung', ok: Boolean(listing.description?.trim()) }, + { label: 'Preis', ok: Number(listing.price) > 0 }, + { label: 'Bilder', ok: Boolean(listing.photos?.length) }, + { label: 'Kategorie', ok: Boolean(listing.category?.trim()) }, + { label: 'Zustand', ok: Boolean(listing.condition?.trim()) }, + ]; +} + +function publishReadinessScore(listing) { + const items = publishReadinessItems(listing); + return Math.round((items.filter(item => item.ok).length / items.length) * 100); +} + +function renderDirectPublishWorkspace(listing) { + const workspace = document.getElementById('publish-workspace'); + const target = publishPlatformTarget(listing.platform); + const connected = listing.platform === 'ebay-de' + ? Boolean(publishState.integrations.ebay?.connected) + : listing.platform === 'etsy' + ? Boolean(publishState.integrations.etsy?.connected) + : false; + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || ''); + + workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Direkt veröffentlichen', target.direct ? (connected ? 'Verbindung bereit' : 'Nicht verbunden') : 'Smart Copy erforderlich')} +
                                                                      +
                                                                      +
                                                                      ${String(listing.title || '').length}/80
                                                                      +
                                                                      ${String(listing.description || '').length}/5000
                                                                      +
                                                                      ${Array.isArray(listing.tags) ? listing.tags.length : 0}
                                                                      +
                                                                      + + + + + + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      ${listing.platform === 'ebay-de' ? '' : ''}
                                                                      +
                                                                      `; + + bindPublishWorkspaceNavigation(workspace); + const title = document.getElementById('publish-direct-title'); + const description = document.getElementById('publish-direct-description'); + title?.addEventListener('input', () => { document.getElementById('publish-direct-title-count').textContent = `${title.value.length}/80`; }); + description?.addEventListener('input', () => { document.getElementById('publish-direct-description-count').textContent = `${description.value.length}/5000`; }); + workspace.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); }); + workspace.querySelector('[data-publish-save-direct]')?.addEventListener('click', () => saveDirectPublishDraft(listing)); + workspace.querySelector('[data-add-current-ebay]')?.addEventListener('click', async () => { + publishState.selected.add(listing.id); updatePublishToolbar(); await batchEbayAdd(); + }); + workspace.querySelector('[data-publish-direct]')?.addEventListener('click', async event => { + if (!target.direct) { setPublishView('smart'); openSmartCopyModal(listing); return; } + if (!connected) { toast(`${target.name} ist nicht verbunden`); switchToTab('settings'); return; } + const saved = await saveDirectPublishDraft(listing, false); + if (!saved) return; + const button = event.currentTarget; + button.disabled = true; button.textContent = 'Wird eingereiht …'; + try { + if (listing.platform === 'etsy') await api('POST', '/api/publish/etsy', { listing_id: listing.id }); + else await api('POST', `/api/ebay/publish/${listing.id}`); + toast(`Auftrag für ${target.name} wurde sicher eingereiht`); + notify('Vendoo Publish', `${listing.title || listing.sku} wurde zur Publishing-Queue hinzugefügt.`); + await loadPublishList(); + } catch (error) { toast(`Veröffentlichung fehlgeschlagen: ${error.message}`); } + finally { button.disabled = false; button.textContent = 'Jetzt veröffentlichen'; } + }); +} + +async function saveDirectPublishDraft(listing, showSuccess = true) { + const payload = { + title: document.getElementById('publish-direct-title')?.value.trim() || '', + description: document.getElementById('publish-direct-description')?.value.trim() || '', + tags: (document.getElementById('publish-direct-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean), + price: Number.parseFloat(document.getElementById('publish-direct-price')?.value) || null, + condition: document.getElementById('publish-direct-condition')?.value.trim() || null, + brand: document.getElementById('publish-direct-brand')?.value.trim() || null, + size: document.getElementById('publish-direct-size')?.value.trim() || null, + color: document.getElementById('publish-direct-color')?.value.trim() || null, + category: document.getElementById('publish-direct-category')?.value.trim() || null, + }; + try { + const updated = await api('PUT', `/api/listings/${listing.id}`, payload); + const replace = items => items.map(item => item.id === updated.id ? updated : item); + publishState.listings = replace(publishState.listings); + publishState.allFiltered = replace(publishState.allFiltered); + if (showSuccess) toast('Artikel gespeichert'); + return updated; + } catch (error) { + toast(`Speichern fehlgeschlagen: ${error.message}`); + return null; + } +} + +function renderQueueWorkspaceLegacy() { + const workspace = document.getElementById('publish-workspace'); + const queue = publishState.queue; + const counts = publishQueueCounts(); + workspace.innerHTML = `
                                                                      eBay Automatisierung

                                                                      eBay Queue

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

                                                                      +
                                                                      ${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '

                                                                      Queue ist leer

                                                                      Wähle links Artikel aus und füge sie zur eBay Queue hinzu.

                                                                      '}
                                                                      +
                                                                      eBay-Verbindung: ${publishState.integrations.ebay?.connected ? 'Verbunden' : 'Nicht verbunden'}
                                                                      `; + + workspace.querySelector('[data-queue-process]')?.addEventListener('click', processEbayQueue); + workspace.querySelector('[data-queue-retry]')?.addEventListener('click', retryEbayQueue); + workspace.querySelector('[data-queue-clear]')?.addEventListener('click', clearEbayQueueUI); + workspace.querySelector('[data-queue-add-selected]')?.addEventListener('click', batchEbayAdd); + workspace.querySelectorAll('[data-queue-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/ebay/queue/${button.dataset.queueRemove}`); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishQueueRow(item) { + const labels = { pending: 'Ausstehend', uploading: 'In Bearbeitung', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' }; + return `
                                                                      +
                                                                      ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                                                                      ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                                                                      +
                                                                      Status${escapeHtml(labels[item.status] || item.status)}
                                                                      +
                                                                      Versuche${Number(item.retries) || 0}
                                                                      +
                                                                      ${item.error ? `${escapeHtml(item.error)}` : 'Keine Fehler'}
                                                                      +
                                                                      ${item.ebay_url ? `Ansehen` : ''}
                                                                      +
                                                                      `; +} + +function renderScheduledWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const items = publishState.scheduled; + workspace.innerHTML = `
                                                                      Zeitsteuerung

                                                                      Geplante Veröffentlichungen

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

                                                                      +
                                                                      ${items.length ? items.map(item => publishScheduleRow(item)).join('') : '

                                                                      Nichts geplant

                                                                      Plane ausgewählte Artikel für einen späteren Zeitpunkt.

                                                                      '}
                                                                      `; + + workspace.querySelector('[data-schedule-selected]')?.addEventListener('click', openBatchSchedule); + workspace.querySelectorAll('[data-schedule-remove]').forEach(button => button.addEventListener('click', async () => { + try { await api('DELETE', `/api/schedule/${button.dataset.scheduleRemove}`); toast('Planung entfernt'); await loadPublishList(); } + catch (error) { toast(`Fehler: ${error.message}`); } + })); +} + +function publishScheduleRow(item) { + const statusLabels = { scheduled: 'Geplant', publishing: 'In Bearbeitung', queued: 'An Queue übergeben', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' }; + const date = new Date(String(item.scheduled_at || '').replace(' ', 'T') + (String(item.scheduled_at || '').includes('Z') ? '' : 'Z')); + const dateText = Number.isNaN(date.getTime()) ? item.scheduled_at : date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); + return `
                                                                      +
                                                                      ${escapeHtml(dateText || '—')}${escapeHtml(platformLabel(item.platform))}
                                                                      +
                                                                      ${escapeHtml(String(item.sku || 'VD').slice(-2))}
                                                                      ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
                                                                      + ${escapeHtml(statusLabels[item.status] || item.status)} + ${item.error ? `${escapeHtml(item.error)}` : ''} + ${item.status === 'scheduled' ? `` : ''} +
                                                                      `; +} + +function publishQueueCountsLegacy() { + return publishState.queue.reduce((counts, item) => { + if (item.status === 'pending') counts.pending++; + else if (item.status === 'uploading') counts.processing++; + else if (item.status === 'published') counts.published++; + else if (item.status === 'failed') counts.failed++; + return counts; + }, { pending: 0, processing: 0, published: 0, failed: 0 }); +} + +function renderPublishStatusRailLegacy() { + const counts = publishQueueCounts(); + const workflowPublished = publishState.workflow?.stages?.published?.count || 0; + const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length; + const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; }; + setText('publish-status-pending', counts.pending); + setText('publish-status-processing', counts.processing); + setText('publish-status-published', Math.max(counts.published, workflowPublished)); + setText('publish-status-failed', counts.failed); + setText('publish-status-scheduled', publishState.scheduled.length); + setText('publish-direct-count', directEligible); + setText('publish-queue-count', publishState.queue.length); + setText('publish-scheduled-count', publishState.scheduled.length); + setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler warten` : 'Keine Fehler'); + setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen'); + + const activity = publishState.workflow?.activity || []; + const container = document.getElementById('publish-activity'); + if (container) { + container.innerHTML = activity.length ? activity.slice(0, 6).map(item => `
                                                                      ${activityIcon(item.type)}
                                                                      ${escapeHtml(item.text || '')}${relativeTime(item.at)}
                                                                      `).join('') : '
                                                                      Noch keine Publish-Aktivität
                                                                      '; + } +} + +async function openSmartCopy(listingId) { + publishState.selectedId = Number(listingId); + setPublishView('smart'); + renderPublishList(); + openSmartCopyModal(selectedPublishListing()); +} + +async function batchSmartCopy() { + const id = [...publishState.selected][0] || publishState.selectedId; + if (!id) return; + openSmartCopy(id); +} + +async function batchEtsyPublish() { + const ids = [...publishState.selected]; + const eligible = publishState.listings.filter(listing => ids.includes(listing.id) && listing.platform === 'etsy'); + if (!eligible.length) { toast('Keine ausgewählten Etsy-Artikel'); return; } + let succeeded = 0; + let failed = 0; + for (const listing of eligible) { + try { await api('POST', '/api/publish/etsy', { listing_id: listing.id }); succeeded++; } + catch { failed++; } + } + toast(`Etsy: ${succeeded} Auftrag${succeeded === 1 ? '' : 'e'} eingereiht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); +} + +async function batchEbayAddLegacy() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const response = await api('POST', '/api/ebay/queue', { listing_ids: ids }); + toast(`${response.added} Listing${response.added === 1 ? '' : 's'} zur eBay Queue hinzugefügt`); + publishState.view = 'queue'; + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function openEbayQueueLegacy() { setPublishView('queue'); } + +async function processEbayQueueLegacy() { + if (!publishState.integrations.ebay?.connected) { toast('eBay ist nicht verbunden'); switchToTab('settings'); return; } + const buttons = document.querySelectorAll('[data-queue-process], #publish-ebay-queue-process'); + buttons.forEach(button => { button.disabled = true; }); + try { + const response = await api('POST', '/api/ebay/queue/process'); + const succeeded = response.results?.filter(item => item.status === 'published').length || 0; + const failed = response.results?.filter(item => item.status === 'failed').length || 0; + toast(`${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + notify('Vendoo — eBay Queue', `${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Queue-Fehler: ${error.message}`); } + finally { buttons.forEach(button => { button.disabled = false; }); } +} + +async function retryEbayQueueLegacy() { + try { + const response = await api('POST', '/api/ebay/queue/retry'); + toast(`${response.retried} Einträge erneut eingeplant`); + await loadPublishList(); + setPublishView('queue'); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function clearEbayQueueUI() { + if (!confirm('eBay Queue wirklich vollständig leeren?')) return; + try { await api('DELETE', '/api/ebay/queue'); toast('eBay Queue geleert'); await loadPublishList(); setPublishView('queue'); } + catch (error) { toast(`Fehler: ${error.message}`); } +} + +let scheduleListingIds = []; + +function openBatchSchedule() { + scheduleListingIds = [...publishState.selected]; + if (!scheduleListingIds.length && publishState.selectedId) scheduleListingIds = [publishState.selectedId]; + if (!scheduleListingIds.length) { toast('Bitte mindestens ein Artikel auswählen'); return; } + openPublishScheduleDialog(scheduleListingIds); +} + +function openSingleSchedule(listingId) { + scheduleListingIds = [Number(listingId)]; + openPublishScheduleDialog(scheduleListingIds); +} + +function openPublishScheduleDialog(ids) { + const now = new Date(Date.now() + 30 * 60 * 1000); + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + const input = document.getElementById('schedule-datetime'); + input.value = local; + input.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); + document.getElementById('schedule-modal-title').textContent = ids.length === 1 ? 'Veröffentlichung planen' : `${ids.length} Veröffentlichungen planen`; + const selected = publishState.listings.filter(listing => ids.includes(listing.id)); + document.getElementById('schedule-listing-summary').innerHTML = selected.slice(0, 4).map(listing => `${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}`).join('') + (selected.length > 4 ? `+${selected.length - 4} weitere` : ''); + document.getElementById('schedule-modal').classList.remove('hidden'); + + document.getElementById('schedule-confirm').onclick = async () => { + const dateTime = input.value; + const platform = document.getElementById('schedule-platform').value; + if (!dateTime) { toast('Bitte Datum und Uhrzeit auswählen'); return; } + const utc = new Date(dateTime).toISOString().replace('T', ' ').slice(0, 19); + let succeeded = 0; + let failed = 0; + for (const id of ids) { + try { await api('POST', '/api/schedule', { listing_id: id, platform, scheduled_at: utc }); succeeded++; } + catch { failed++; } + } + toast(`${succeeded} Veröffentlichung${succeeded === 1 ? '' : 'en'} geplant${failed ? `, ${failed} fehlgeschlagen` : ''}`); + closePublishSchedule(); + publishState.view = 'scheduled'; + await loadPublishList(); + setPublishView('scheduled'); + }; +} + +function closePublishSchedule() { + document.getElementById('schedule-modal')?.classList.add('hidden'); +} + +async function openScheduleOverview() { setPublishView('scheduled'); } + +async function batchDownloadPhotos() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + try { + const data = await api('POST', '/api/publish/batch-photos', { listing_ids: ids }); + await downloadPublishPhotos(data.photos || []); + } catch (error) { toast(`Fehler: ${error.message}`); } +} + +async function downloadPublishPhotos(photos) { + const unique = [...new Set((photos || []).filter(Boolean))]; + if (!unique.length) { toast('Keine Fotos vorhanden'); return; } + if (unique.length === 1) { + const photo = unique[0]; + const link = document.createElement('a'); + link.href = photoUrl(photo); + link.download = String(photo).split('/').pop() || 'vendoo-foto.jpg'; + document.body.appendChild(link); + link.click(); + link.remove(); + toast('Foto wird gespeichert'); + return; + } + + try { + const response = await fetch('/api/photos/archive', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ photos: unique }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'ZIP konnte nicht erstellt werden'); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `vendoo-fotos-${new Date().toISOString().slice(0, 10)}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + toast(`${unique.length} Fotos wurden als ZIP gepackt`); + } catch (error) { + toast(`Foto-ZIP fehlgeschlagen: ${error.message}`); + } +} + + + +// --- Central image gallery / media library --- +const MEDIA_SOURCE_LABELS = { flux: 'FLUX', edited: 'Bearbeitet', batch: 'Produktion', ai: 'AI-Bild', listing: 'Artikel' }; + +function formatMediaFileSize(bytes) { + const size = Number(bytes || 0); + if (!size) return 'Größe unbekannt'; + if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`; + return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`; +} + +function renderMediaSummary() { + const summary = state.mediaSummary || { total: 0, sources: {} }; + const sources = summary.sources || {}; + const values = { total: summary.total || 0, flux: sources.flux || 0, edited: sources.edited || 0, batch: sources.batch || 0, ai: sources.ai || 0, listing: sources.listing || 0 }; + for (const [key, value] of Object.entries(values)) document.getElementById(`media-count-${key}`)?.replaceChildren(document.createTextNode(String(value))); + const current = document.getElementById('media-source')?.value || 'all'; + document.querySelectorAll('[data-media-source]').forEach(button => button.classList.toggle('active', button.dataset.mediaSource === current)); +} + +function updateMediaToolbar() { + const count = state.mediaSelected.size; + document.getElementById('media-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`)); + const download = document.getElementById('media-download-selected'); + const remove = document.getElementById('media-delete-selected'); + const factory = document.getElementById('media-send-to-factory'); + if (download) download.disabled = count === 0; + if (remove) remove.disabled = count === 0; + if (factory) factory.disabled = count === 0; + const selectPage = document.getElementById('media-select-page'); + if (selectPage) { + const selectedOnPage = state.mediaItems.filter(item => state.mediaSelected.has(item.image_path)).length; + selectPage.checked = state.mediaItems.length > 0 && selectedOnPage === state.mediaItems.length; + selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.mediaItems.length; + } +} + +function renderMediaPagination() { + const pagination = state.mediaPagination || {}; + const page = Math.max(1, Number(pagination.page || 1)); + const pages = Math.max(1, Number(pagination.total_pages || 1)); + const total = Math.max(0, Number(pagination.total || 0)); + const pageSize = Math.max(1, Number(state.mediaPageSize || 24)); + const first = total ? ((page - 1) * pageSize) + 1 : 0; + const last = total ? Math.min(total, first + state.mediaItems.length - 1) : 0; + document.querySelectorAll('[data-media-pagination]').forEach(paginationRoot => { + paginationRoot.querySelector('[data-media-page-range]')?.replaceChildren(document.createTextNode(total ? `${first}–${last} von ${total} Bildern` : '0 Bilder')); + paginationRoot.querySelector('[data-media-page-info]')?.replaceChildren(document.createTextNode(`von ${pages}`)); + const select = paginationRoot.querySelector('[data-media-page-jump]'); + if (select) { + const signature = `${pages}:${page}`; + if (select.dataset.signature !== signature) { + select.innerHTML = Array.from({ length: pages }, (_, index) => ``).join(''); + select.dataset.signature = signature; + } else select.value = String(page); + } + const prev = paginationRoot.querySelector('[data-media-page-action="prev"]'); + const next = paginationRoot.querySelector('[data-media-page-action="next"]'); + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = page >= pages; + }); +} + +function mediaSourceBadges(item) { + return (item.sources || [item.source]).map(source => `${escapeHtml(MEDIA_SOURCE_LABELS[source] || source)}`).join(''); +} + +function mediaActionIcon(name, size = 18) { + const map = { open: 'zoom-in', edit: 'pencil', use: 'plus', download: 'download', delete: 'trash-2' }; + return window.VendooIcons?.svg(map[name] || name, { size, className: 'media-action-icon' }) || ''; +} + +function renderMediaLibrary() { + const container = document.getElementById('media-grid'); + if (!container) return; + container.classList.toggle('is-list', state.mediaView === 'list'); + if (!state.mediaItems.length) { + container.innerHTML = '
                                                                      Keine Bilder gefundenFilter anpassen oder im FLUX Studio ein neues Bild erzeugen.
                                                                      '; + container.querySelector('[data-tab-target]')?.addEventListener('click', () => switchToTab('flux-studio')); + updateMediaToolbar(); + return; + } + container.innerHTML = state.mediaItems.map(item => { + const selected = state.mediaSelected.has(item.image_path); + const title = item.title || item.prompt || item.image_path.split('/').pop(); + const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : 'Abmessungen unbekannt'; + const canFavorite = (item.sources || []).includes('flux'); + return `
                                                                      +
                                                                      + + + ${canFavorite ? `` : ''} +
                                                                      +
                                                                      +
                                                                      ${mediaSourceBadges(item)}
                                                                      + ${escapeHtml(title)} + ${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))} + ${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())} +
                                                                      +
                                                                      + + + ${mediaActionIcon('download')}Download + +
                                                                      +
                                                                      `; + }).join(''); + container.querySelectorAll('[data-media-select]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.mediaSelected.add(input.dataset.mediaSelect); else state.mediaSelected.delete(input.dataset.mediaSelect); + input.closest('.media-card')?.classList.toggle('is-selected', input.checked); + updateMediaToolbar(); + })); + container.querySelectorAll('[data-media-preview]').forEach(button => button.addEventListener('click', () => openMediaLightbox(findMediaItem(button.dataset.mediaPreview)))); + container.querySelectorAll('[data-media-edit]').forEach(button => button.addEventListener('click', () => openMediaEditor(findMediaItem(button.dataset.mediaEdit)))); + container.querySelectorAll('[data-media-use]').forEach(button => button.addEventListener('click', () => useMediaInGenerator(findMediaItem(button.dataset.mediaUse)))); + container.querySelectorAll('[data-media-favorite]').forEach(button => button.addEventListener('click', () => toggleMediaFavorite(findMediaItem(button.dataset.mediaFavorite)))); + container.querySelectorAll('[data-media-delete]').forEach(button => button.addEventListener('click', () => deleteMediaItems([button.dataset.mediaDelete]))); + updateMediaToolbar(); + window.VendooIcons?.render(container); +} + +function findMediaItem(imagePath) { + return state.mediaItems.find(item => item.image_path === imagePath) || (state.mediaCurrent?.image_path === imagePath ? state.mediaCurrent : null); +} + +async function loadMediaLibrary({ silent = false } = {}) { + const container = document.getElementById('media-grid'); + if (!silent && container && !state.mediaItems.length) container.innerHTML = '
                                                                      Bildergalerie wird geladen …
                                                                      '; + try { + const params = new URLSearchParams({ + page: String(state.mediaPage), page_size: String(state.mediaPageSize), + source: document.getElementById('media-source')?.value || 'all', + sort: document.getElementById('media-sort')?.value || 'newest', + favorites: document.getElementById('media-favorites-only')?.checked ? '1' : '0', + }); + const search = document.getElementById('media-search')?.value.trim(); + if (search) params.set('q', search); + const result = await api('GET', `/api/media-library?${params}`); + state.mediaItems = result.items || []; + state.mediaPagination = result.pagination || { page: 1, total_pages: 1, total: state.mediaItems.length }; + state.mediaSummary = result.summary || null; + state.mediaPage = Number(state.mediaPagination.page || 1); + state.mediaSelected = new Set([...state.mediaSelected].filter(path => state.mediaItems.some(item => item.image_path === path))); + renderMediaSummary(); renderMediaLibrary(); renderMediaPagination(); + } catch (error) { + if (container) container.innerHTML = `
                                                                      Bildergalerie konnte nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +async function toggleMediaFavorite(item) { + if (!item) return; + try { + const result = await api('PATCH', '/api/media-library/favorite', { image_path: item.image_path, favorite: !item.favorite }); + item.favorite = result.favorite; + if (state.mediaCurrent?.image_path === item.image_path) state.mediaCurrent.favorite = result.favorite; + renderMediaLibrary(); renderMediaLightbox(); + } catch (error) { toast(error.message); } +} + +async function deleteMediaItems(imagePaths) { + const paths = [...new Set((imagePaths || []).filter(Boolean))]; + if (!paths.length) return; + if (!window.confirm(`${paths.length} Bild${paths.length === 1 ? '' : 'er'} endgültig aus der Medienbibliothek und vom Datenträger löschen? In Artikeln verwendete Bilder werden geschützt.`)) return; + try { + const result = await api('DELETE', '/api/media-library/items', { image_paths: paths }); + state.mediaSelected.clear(); + if (state.mediaCurrent && paths.includes(state.mediaCurrent.image_path)) closeMediaLightbox(); + const protectedCount = result.protected?.length || 0; + toast(`${result.deleted || 0} Bild${result.deleted === 1 ? '' : 'er'} gelöscht${protectedCount ? ` · ${protectedCount} Listing-Bild${protectedCount === 1 ? '' : 'er'} geschützt` : ''}.`); + await loadMediaLibrary(); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function useMediaInGenerator(item) { + if (!item?.image_path) return; + if (!state.photos.includes(item.image_path)) state.photos.push(item.image_path); + renderPhotoPreview(); + switchToTab('generator'); + toast('Bild wurde dem neuen Artikel hinzugefügt.'); +} + +function openMediaEditor(item) { + if (!item) return; + state.mediaCurrent = item; + if ((item.sources || []).includes('flux') && item.flux_id) { + state.fluxStudioCurrent = { ...item, id: item.flux_id, image_path: item.image_path, public_url: item.public_url }; + openPhotoEditor(0, 'flux'); + } else openPhotoEditor(0, 'media'); +} + +const mediaZoomState = { zoom: 1, fitZoom: 1, naturalWidth: 0, naturalHeight: 0, dragging: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 }; + +function updateMediaZoom(zoom, { center = false } = {}) { + const stage = document.getElementById('media-lightbox-stage'); + const canvas = document.getElementById('media-lightbox-canvas'); + const image = document.getElementById('media-lightbox-image'); + if (!stage || !canvas || !image || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return; + const previousCenterX = stage.scrollLeft + stage.clientWidth / 2; + const previousCenterY = stage.scrollTop + stage.clientHeight / 2; + const previousWidth = Math.max(1, mediaZoomState.naturalWidth * mediaZoomState.zoom); + const previousHeight = Math.max(1, mediaZoomState.naturalHeight * mediaZoomState.zoom); + mediaZoomState.zoom = Math.min(4, Math.max(.1, Number(zoom) || 1)); + const width = Math.max(1, Math.round(mediaZoomState.naturalWidth * mediaZoomState.zoom)); + const height = Math.max(1, Math.round(mediaZoomState.naturalHeight * mediaZoomState.zoom)); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + image.style.width = '100%'; + image.style.height = '100%'; + const range = document.getElementById('media-zoom-range'); + const output = document.getElementById('media-zoom-value'); + if (range) range.value = String(Math.round(mediaZoomState.zoom * 100)); + if (output) output.value = output.textContent = `${Math.round(mediaZoomState.zoom * 100)} %`; + if (center) { + const ratioX = previousCenterX / previousWidth; + const ratioY = previousCenterY / previousHeight; + requestAnimationFrame(() => { + stage.scrollLeft = Math.max(0, ratioX * width - stage.clientWidth / 2); + stage.scrollTop = Math.max(0, ratioY * height - stage.clientHeight / 2); + }); + } +} + +function fitMediaLightbox() { + const stage = document.getElementById('media-lightbox-stage'); + if (!stage || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return; + const availableWidth = Math.max(120, stage.clientWidth - 28); + const availableHeight = Math.max(120, stage.clientHeight - 28); + mediaZoomState.fitZoom = Math.min(1, availableWidth / mediaZoomState.naturalWidth, availableHeight / mediaZoomState.naturalHeight); + updateMediaZoom(mediaZoomState.fitZoom); + requestAnimationFrame(() => { stage.scrollLeft = 0; stage.scrollTop = 0; }); +} + +function renderMediaLightbox() { + const item = state.mediaCurrent; + const dialog = document.getElementById('media-lightbox'); + if (!dialog || dialog.classList.contains('hidden') || !item) return; + document.getElementById('media-lightbox-title').textContent = item.title || item.prompt || item.image_path.split('/').pop(); + const image = document.getElementById('media-lightbox-image'); + image.onload = () => { + mediaZoomState.naturalWidth = image.naturalWidth || Number(item.width) || 1; + mediaZoomState.naturalHeight = image.naturalHeight || Number(item.height) || 1; + fitMediaLightbox(); + }; + image.src = item.public_url || photoUrl(item.image_path); + document.getElementById('media-lightbox-download').href = item.public_url || photoUrl(item.image_path); + document.getElementById('media-lightbox-download').download = item.image_path.split('/').pop(); + const favorite = document.getElementById('media-lightbox-favorite'); + const canFavorite = (item.sources || []).includes('flux'); + favorite?.classList.toggle('hidden', !canFavorite); + if (favorite) favorite.innerHTML = `${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 17 }) || ''} ${item.favorite ? 'Favorit entfernen' : 'Favorit'}`; + document.getElementById('media-lightbox-meta').innerHTML = ` +
                                                                      ${mediaSourceBadges(item)}
                                                                      +
                                                                      Datei
                                                                      ${escapeHtml(item.image_path)}
                                                                      Abmessungen
                                                                      ${item.width && item.height ? `${item.width} × ${item.height}` : 'Unbekannt'}
                                                                      Dateigröße
                                                                      ${escapeHtml(formatMediaFileSize(item.file_size))}
                                                                      Format
                                                                      ${escapeHtml(String(item.format || '—').toUpperCase())}
                                                                      Erstellt
                                                                      ${escapeHtml(formatFluxDate(item.created_at))}
                                                                      ${item.seed != null ? `
                                                                      Seed
                                                                      ${item.seed}
                                                                      ` : ''}
                                                                      + ${item.prompt ? `
                                                                      Prompt

                                                                      ${escapeHtml(item.prompt)}

                                                                      ` : ''}`; + window.VendooIcons?.render(dialog); +} + +function openMediaLightbox(item) { + if (!item) return; + state.mediaCurrent = item; + const dialog = document.getElementById('media-lightbox'); + dialog?.classList.remove('hidden'); + dialog?.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + mediaZoomState.zoom = 1; + renderMediaLightbox(); +} + +function closeMediaLightbox() { + const dialog = document.getElementById('media-lightbox'); + dialog?.classList.add('hidden'); + dialog?.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('modal-open'); + const image = document.getElementById('media-lightbox-image'); + if (image) image.onload = null; +} + +function setupMediaLibrary() { + const root = document.getElementById('media-library'); + if (!root) return; + const resetAndLoad = () => { state.mediaPage = 1; state.mediaSelected.clear(); loadMediaLibrary(); }; + document.getElementById('media-refresh')?.addEventListener('click', () => loadMediaLibrary()); + document.getElementById('media-source')?.addEventListener('change', resetAndLoad); + document.getElementById('media-sort')?.addEventListener('change', resetAndLoad); + document.getElementById('media-favorites-only')?.addEventListener('change', resetAndLoad); + document.getElementById('media-page-size')?.addEventListener('change', event => { state.mediaPageSize = Number(event.target.value) || 24; resetAndLoad(); }); + document.getElementById('media-search')?.addEventListener('input', debounce(resetAndLoad, 280)); + document.querySelectorAll('[data-media-source]').forEach(button => button.addEventListener('click', () => { + const select = document.getElementById('media-source'); if (select) select.value = button.dataset.mediaSource; resetAndLoad(); + })); + document.getElementById('media-view-grid')?.addEventListener('click', () => { state.mediaView = 'grid'; document.getElementById('media-view-grid').classList.add('active'); document.getElementById('media-view-list').classList.remove('active'); renderMediaLibrary(); }); + document.getElementById('media-view-list')?.addEventListener('click', () => { state.mediaView = 'list'; document.getElementById('media-view-list').classList.add('active'); document.getElementById('media-view-grid').classList.remove('active'); renderMediaLibrary(); }); + document.getElementById('media-select-page')?.addEventListener('change', event => { for (const item of state.mediaItems) { if (event.target.checked) state.mediaSelected.add(item.image_path); else state.mediaSelected.delete(item.image_path); } renderMediaLibrary(); }); + document.getElementById('media-download-selected')?.addEventListener('click', () => downloadPublishPhotos([...state.mediaSelected])); + document.getElementById('media-delete-selected')?.addEventListener('click', () => deleteMediaItems([...state.mediaSelected])); + root.querySelectorAll('[data-media-page-action]').forEach(button => button.addEventListener('click', () => { + const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1)); + const target = button.dataset.mediaPageAction === 'prev' ? state.mediaPage - 1 : state.mediaPage + 1; + if (target >= 1 && target <= pages && target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); } + })); + root.querySelectorAll('[data-media-page-jump]').forEach(select => select.addEventListener('change', () => { + const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1)); + const target = Math.min(pages, Math.max(1, Number(select.value || 1))); + if (target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); } + })); + document.querySelectorAll('[data-media-lightbox-close]').forEach(button => button.addEventListener('click', closeMediaLightbox)); + document.getElementById('media-lightbox-edit')?.addEventListener('click', () => { closeMediaLightbox(); openMediaEditor(state.mediaCurrent); }); + document.getElementById('media-lightbox-favorite')?.addEventListener('click', () => toggleMediaFavorite(state.mediaCurrent)); + document.getElementById('media-lightbox-delete')?.addEventListener('click', () => deleteMediaItems(state.mediaCurrent ? [state.mediaCurrent.image_path] : [])); + document.getElementById('media-zoom-in')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom + .15, { center: true })); + document.getElementById('media-zoom-out')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom - .15, { center: true })); + document.getElementById('media-zoom-fit')?.addEventListener('click', fitMediaLightbox); + document.getElementById('media-zoom-original')?.addEventListener('click', () => updateMediaZoom(1, { center: true })); + document.getElementById('media-zoom-range')?.addEventListener('input', event => updateMediaZoom(Number(event.target.value) / 100, { center: true })); + const zoomStage = document.getElementById('media-lightbox-stage'); + zoomStage?.addEventListener('wheel', event => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + updateMediaZoom(mediaZoomState.zoom + (event.deltaY < 0 ? .1 : -.1), { center: true }); + }, { passive: false }); + zoomStage?.addEventListener('pointerdown', event => { + if (event.button !== 0 || mediaZoomState.zoom <= mediaZoomState.fitZoom + .01) return; + mediaZoomState.dragging = true; mediaZoomState.startX = event.clientX; mediaZoomState.startY = event.clientY; + mediaZoomState.scrollLeft = zoomStage.scrollLeft; mediaZoomState.scrollTop = zoomStage.scrollTop; + zoomStage.setPointerCapture(event.pointerId); zoomStage.classList.add('is-dragging'); + }); + zoomStage?.addEventListener('pointermove', event => { + if (!mediaZoomState.dragging) return; + zoomStage.scrollLeft = mediaZoomState.scrollLeft - (event.clientX - mediaZoomState.startX); + zoomStage.scrollTop = mediaZoomState.scrollTop - (event.clientY - mediaZoomState.startY); + }); + const stopZoomDrag = event => { mediaZoomState.dragging = false; zoomStage?.classList.remove('is-dragging'); try { zoomStage?.releasePointerCapture(event.pointerId); } catch {} }; + zoomStage?.addEventListener('pointerup', stopZoomDrag); zoomStage?.addEventListener('pointercancel', stopZoomDrag); + document.addEventListener('keydown', event => { + if (document.getElementById('media-lightbox')?.classList.contains('hidden')) return; + if (event.key === 'Escape') closeMediaLightbox(); + if (event.key === '+' || event.key === '=') updateMediaZoom(mediaZoomState.zoom + .15, { center: true }); + if (event.key === '-') updateMediaZoom(mediaZoomState.zoom - .15, { center: true }); + if (event.key === '0') fitMediaLightbox(); + }); +} + +// --- Browser extensions --- +const EXTENSION_BROWSERS = { + chrome: { name: 'Google Chrome', icon: 'chrome', managementUrl: 'chrome://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' }, + edge: { name: 'Microsoft Edge', icon: 'edge', managementUrl: 'edge://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' }, + firefox: { name: 'Mozilla Firefox', icon: 'firefox', managementUrl: 'about:debugging#/runtime/this-firefox', action: 'Temporäres Add-on laden', note: 'Für eine dauerhaft signierte Installation muss das Add-on über Mozilla signiert werden.' }, + safari: { name: 'Apple Safari', icon: 'compass', managementUrl: 'Safari → Einstellungen → Erweiterungen', action: 'Safari Web Extension erstellen', note: 'Benötigt macOS und Xcode. Vendoo liefert WebExtension-Quelle und Konvertierungsskript mit.' }, +}; + +function detectActiveBrowser() { + const ua = navigator.userAgent || ''; + if (/Edg\//.test(ua)) return 'edge'; + if (/Firefox\//.test(ua)) return 'firefox'; + if (/Safari\//.test(ua) && !/Chrome\//.test(ua) && !/Chromium\//.test(ua)) return 'safari'; + if (/Chrome\//.test(ua) || /Chromium\//.test(ua)) return 'chrome'; + return 'chrome'; +} + +async function copyTextSafe(value) { + try { await navigator.clipboard.writeText(String(value || '')); return true; } + catch { + const area = document.createElement('textarea'); area.value = String(value || ''); area.style.position = 'fixed'; area.style.opacity = '0'; + document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok; + } +} + +async function prepareBrowserExtension(browser, data = {}) { + const config = EXTENSION_BROWSERS[browser]; + if (!config) return; + try { + const result = await api('POST', '/api/extensions/prepare', { browser }); + await copyTextSafe(result.path || ''); + const card = document.querySelector(`[data-extension-browser="${browser}"]`); + card?.querySelector('.extension-path code')?.replaceChildren(document.createTextNode(result.path || '')); + toast(result.opened + ? `Installationsordner geöffnet und Pfad kopiert. Öffne jetzt ${config.managementUrl}.` + : `Ordnerpfad kopiert: ${result.path}`); + return result; + } catch (error) { + toast(`Extension konnte nicht vorbereitet werden: ${error.message}`); + throw error; + } +} + +function renderExtensions(data = {}) { + const grid = document.getElementById('extensions-grid'); + if (!grid) return; + const active = detectActiveBrowser(); + const activeBrowser = EXTENSION_BROWSERS[active]; + const activeBox = document.getElementById('extensions-active-browser'); + if (activeBox) activeBox.innerHTML = `Aktiver Browser${window.VendooIcons?.svg(activeBrowser.icon, { size: 19 }) || ''} ${escapeHtml(activeBrowser.name)}`; + const version = data.version || '1.9.0'; + grid.innerHTML = Object.entries(EXTENSION_BROWSERS).map(([id, browser]) => { + const info = data.browsers?.[id] || {}; + return `
                                                                      +
                                                                      ${window.VendooIcons?.svg(browser.icon, { size: 27 }) || ''}
                                                                      ${id === active ? 'Aktiver Browser' : 'Verfügbar'}

                                                                      ${escapeHtml(browser.name)}

                                                                      v${escapeHtml(version)}
                                                                      +

                                                                      ${escapeHtml(browser.note)}

                                                                      +
                                                                      Installationsordner${escapeHtml(info.path || 'Wird vom Server ermittelt …')}
                                                                      +
                                                                      1. Installation vorbereiten
                                                                      2. ${escapeHtml(browser.managementUrl)} öffnen
                                                                      3. ${escapeHtml(browser.action)}
                                                                      +
                                                                      + + + ${window.VendooIcons?.svg('download', { size: 17 }) || ''} +
                                                                      + ${id === 'safari' ? 'Safari lässt Erweiterungen nur über eine signierte macOS-App zu. Das Paket enthält dafür ein Xcode-Konvertierungsskript.' : 'Die finale Bestätigung erfolgt im Browser; eine stille automatische Installation ist aus Sicherheitsgründen gesperrt.'} +
                                                                      `; + }).join(''); + grid.querySelectorAll('[data-extension-prepare]').forEach(button => button.addEventListener('click', async () => { + const browser = button.dataset.extensionPrepare; + const old = button.innerHTML; button.disabled = true; button.textContent = 'Wird vorbereitet …'; + try { await prepareBrowserExtension(browser, data); } catch {} + finally { button.disabled = false; button.innerHTML = old; } + })); + grid.querySelectorAll('[data-extension-copy-path]').forEach(button => button.addEventListener('click', async () => { + const path = data.browsers?.[button.dataset.extensionCopyPath]?.path || button.closest('.extension-card')?.querySelector('.extension-path code')?.textContent || ''; + await copyTextSafe(path); toast('Ordnerpfad kopiert'); + })); + window.VendooIcons?.render(document.getElementById('extensions')); +} + +async function loadExtensions() { + const grid = document.getElementById('extensions-grid'); + if (grid && !grid.children.length) grid.innerHTML = '
                                                                      Extensions werden geprüft …
                                                                      '; + try { renderExtensions(await api('GET', '/api/extensions/info')); } + catch (error) { if (grid) grid.innerHTML = `
                                                                      Extension-Informationen konnten nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; } +} + +function setupExtensions() { + window.VendooIcons?.render(document.getElementById('extensions') || document); + document.getElementById('extensions-prepare-active')?.addEventListener('click', async event => { + const button = event.currentTarget; + const browser = detectActiveBrowser(); + const old = button.innerHTML; + button.disabled = true; + button.textContent = 'Wird vorbereitet …'; + try { await prepareBrowserExtension(browser); } catch {} + finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); } + }); +} + +// --- Settings --- +async function detectPublicBaseUrl({ apply = true } = {}) { + const input = document.getElementById('setting-public-base-url'); + const status = document.getElementById('public-base-url-status'); + const button = document.getElementById('detect-public-base-url-btn'); + if (button) button.disabled = true; + if (status) status.textContent = 'LAN-Adresse wird erkannt …'; + try { + const info = await api('GET', '/api/network-info'); + const preferred = info.public_base_url || info.preferred_url || ''; + if (apply && input && preferred) input.value = preferred; + const adapter = info.addresses?.[0]; + if (status) { + status.textContent = adapter + ? `Erkannt: ${preferred} · Adapter: ${adapter.name}` + : `Keine LAN-IP erkannt. Aktuelle Adresse: ${preferred || 'localhost'}`; + } + return info; + } catch (err) { + if (status) status.textContent = `LAN-Adresse konnte nicht erkannt werden: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + + +function renderLocalFluxInstallState(install = {}) { + const wrap = document.getElementById('local-flux-progress-wrap'); + const label = document.getElementById('local-flux-progress-label'); + const value = document.getElementById('local-flux-progress-value'); + const bar = document.getElementById('local-flux-progress-bar'); + const phase = document.getElementById('local-flux-phase-label'); + const updated = document.getElementById('local-flux-updated-at'); + const checksWrap = document.getElementById('local-flux-checks'); + const errorWrap = document.getElementById('local-flux-error'); + const logTail = document.getElementById('local-flux-log-tail'); + const sevenZipTail = document.getElementById('local-flux-7zip-tail'); + const installButton = document.getElementById('install-local-image-provider-btn'); + const installerState = document.getElementById('local-flux-installer-state'); + const elapsed = document.getElementById('local-flux-elapsed'); + const extractFiles = document.getElementById('local-flux-extract-files'); + const extractSize = document.getElementById('local-flux-extract-size'); + const heartbeat = document.getElementById('local-flux-heartbeat'); + + const progress = Math.max(0, Math.min(100, Number(install.progress ?? install.readiness ?? 0))); + if (wrap) wrap.classList.remove('hidden'); + if (label) label.textContent = install.message || install.phase_label || 'Lokale FLUX-Installation'; + if (value) value.textContent = `${progress}%`; + if (bar) { + bar.style.width = `${progress}%`; + bar.classList.toggle('is-stalled', install.status === 'stalled'); + bar.classList.toggle('is-failed', install.status === 'failed'); + } + if (phase) { + const phaseText = install.installation_complete ? 'Vollständig installiert' : (install.phase_label || install.phase || 'Nicht installiert'); + phase.textContent = `${phaseText} · ${Number(install.readiness || 0)}% geprüft`; + } + if (updated) { + const date = install.updated_at ? new Date(install.updated_at) : null; + updated.textContent = date && !Number.isNaN(date.getTime()) ? `Stand: ${date.toLocaleString('de-DE')}` : 'Stand: –'; + } + + const metrics = install.extract_metrics || {}; + if (installerState) { + const labels = { running: 'Läuft', completed: 'Fertig', failed: 'Fehler', stalled: 'Hängt', 'not-started': 'Nicht gestartet', unknown: 'Unbekannt' }; + installerState.textContent = labels[install.status] || install.status || 'Bereit'; + installerState.className = `local-flux-state-value is-${escapeHtml(install.status || 'idle')}`; + } + if (elapsed) elapsed.textContent = `${Number(install.elapsed_seconds || metrics.elapsed_seconds || 0)} s`; + if (extractFiles) extractFiles.textContent = `${Number(metrics.file_count || 0)} Dateien`; + if (extractSize) extractSize.textContent = `${Number(metrics.size_mb || 0)} MB`; + if (heartbeat) { + heartbeat.textContent = install.heartbeat_age_seconds === null || install.heartbeat_age_seconds === undefined + ? '–' + : `${Number(install.heartbeat_age_seconds)} s`; + } + + const checkLabels = { + seven_zip: '7-Zip', + archive: 'ComfyUI-Paket', + embedded_python: 'Python Runtime', + comfyui: 'ComfyUI', + model: 'FLUX-Modell', + workflow: 'Workflow', + start_script: 'Startskript', + stop_script: 'Stoppskript', + }; + if (checksWrap) { + const checks = install.checks || {}; + checksWrap.innerHTML = Object.entries(checkLabels).map(([key, title]) => { + const check = checks[key] || {}; + let detail = check.ok ? 'Vorhanden' : 'Fehlt'; + if (check.size_gb) detail += ` · ${check.size_gb} GB`; + else if (check.size_mb) detail += ` · ${check.size_mb} MB`; + return `
                                                                      ${escapeHtml(title)}${escapeHtml(detail)}
                                                                      `; + }).join(''); + } + + if (errorWrap) { + const failed = install.status === 'failed' || install.status === 'stalled'; + errorWrap.classList.toggle('hidden', !failed); + errorWrap.textContent = failed + ? `${install.message || 'Installation fehlgeschlagen.'}${install.error_position ? ` +${install.error_position}` : ''} +Die vorhandenen Downloads bleiben erhalten. „Installation fortsetzen“ startet nur den fehlenden Schritt erneut.` + : ''; + } + if (logTail) { + const lines = Array.isArray(install.log_tail) ? install.log_tail : []; + logTail.textContent = lines.length ? lines.join('\n') : 'Noch kein Installationsprotokoll vorhanden.'; + } + if (sevenZipTail) { + const lines = [ + ...(Array.isArray(install.seven_zip_error_tail) ? install.seven_zip_error_tail : []), + ...(Array.isArray(install.seven_zip_output_tail) ? install.seven_zip_output_tail : []), + ]; + sevenZipTail.textContent = lines.length ? lines.slice(-40).join('\n') : 'Noch keine 7-Zip-Ausgabe vorhanden.'; + } + + const healthInstall = document.getElementById('local-flux-health-install'); + const healthModel = document.getElementById('local-flux-health-model'); + if (healthInstall) healthInstall.textContent = install.installation_complete ? 'Vollständig' : install.checks?.comfyui?.ok ? 'Unvollständig' : 'Nicht installiert'; + if (healthModel) healthModel.textContent = install.checks?.model?.ok ? 'Vorhanden' : 'Fehlt'; + + if (installButton) { + if (install.status === 'failed' || install.status === 'stalled') installButton.textContent = 'Installation fortsetzen'; + else if (install.checks?.comfyui?.ok && !install.checks?.model?.ok) installButton.textContent = 'FLUX-Modell nachinstallieren'; + else if (install.can_resume) installButton.textContent = 'Installation fortsetzen'; + else if (install.status === 'completed') installButton.textContent = 'Installation prüfen / ergänzen'; + else installButton.textContent = 'Lokales FLUX installieren'; + } +} + +async function getDetailedLocalFluxInstallStatus() { + try { + const install = await api('GET', '/api/local-image/install-status'); + renderLocalFluxInstallState(install); + return install; + } catch (err) { + const status = document.getElementById('local-image-provider-status'); + if (status) status.textContent = `Installationsstatus konnte nicht gelesen werden: ${err.message}`; + return null; + } +} + +let localFluxStatusTimer = null; +let localFluxStatusWatchActive = false; + +async function watchLocalFluxInstallStatus() { + if (localFluxStatusWatchActive) return; + localFluxStatusWatchActive = true; + const run = async () => { + try { + const install = await getDetailedLocalFluxInstallStatus(); + const delay = install && ['running', 'stalled'].includes(install.status) ? 2000 : 10000; + localFluxStatusTimer = setTimeout(run, delay); + } catch { + localFluxStatusTimer = setTimeout(run, 10000); + } + }; + run(); +} + +async function pollLocalFluxInstall(startedAt = Date.now()) { + const status = document.getElementById('local-image-provider-status'); + const pollStarted = Date.now(); + while (Date.now() - pollStarted < 60 * 60 * 1000) { + const install = await getDetailedLocalFluxInstallStatus(); + if (!install) return null; + if (status) status.textContent = install.message || 'Lokale FLUX-Installation läuft ...'; + + const updatedAt = Date.parse(install.updated_at || '') || 0; + const isFreshTerminal = ['completed', 'failed'].includes(install.status) && (updatedAt >= startedAt - 2000 || Date.now() - pollStarted > 8000); + if (isFreshTerminal) { + if (install.status === 'completed') await testLocalImageProvider({ skipInstallFetch: true }); + else if (status) status.textContent = `Installation fehlgeschlagen: ${install.message || 'Unbekannter Fehler'}`; + return install; + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + if (status) status.textContent = 'Die Installation läuft möglicherweise weiter. Nutze „Status prüfen“, um den aktuellen Stand zu laden.'; + return null; +} + + +function updateLocalFluxCommandCenter(result = {}, install = {}) { + const stateName = result.engine_state || (!result.enabled ? 'disabled' : result.reachable ? 'running' : install.installation_complete ? 'stopped' : 'not-installed'); + const labels = { + running: ['Aktiv und einsatzbereit', 'ComfyUI läuft. Vendoo kann lokale AI-Bilder erzeugen.'], + stopped: ['Installiert, aber gestoppt', 'Ein Klick auf „Server starten“ aktiviert die lokale Bild-Engine.'], + disabled: ['Deaktiviert', 'Vendoo nutzt lokales FLUX aktuell nicht. Installation und Dateien bleiben erhalten.'], + 'not-installed': ['Nicht installiert', 'Installiere ComfyUI und FLUX direkt hier – Vendoo führt dich automatisch durch den Vorgang.'], + }; + const [title, detail] = labels[stateName] || labels['not-installed']; + const orb = document.getElementById('local-flux-orb'); + const titleEl = document.getElementById('local-flux-engine-title'); + const detailEl = document.getElementById('local-flux-engine-detail'); + const badge = document.getElementById('local-flux-engine-badge'); + const enableLabel = document.getElementById('local-flux-enable-label'); + const serverHealth = document.getElementById('local-flux-health-server'); + const referenceHealth = document.getElementById('local-flux-health-reference'); + const heroText = document.getElementById('settings-flux-status-text'); + const heroChip = document.getElementById('settings-flux-status-chip'); + if (titleEl) titleEl.textContent = title; + if (detailEl) detailEl.textContent = detail; + if (badge) { badge.textContent = title; badge.className = `flux-state-badge is-${stateName}`; } + if (orb) orb.className = `flux-orb is-${stateName}`; + if (enableLabel) enableLabel.textContent = result.enabled ? 'Aktiviert' : 'Deaktiviert'; + if (serverHealth) serverHealth.textContent = result.reachable ? 'Online' : install.installation_complete ? 'Gestoppt' : 'Nicht verfügbar'; + if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : 'Aktiv'; + if (heroText) heroText.textContent = title; + if (heroChip) heroChip.className = `settings-status-chip is-${stateName}`; + + const startBtn = document.getElementById('start-local-image-provider-btn'); + const stopBtn = document.getElementById('stop-local-image-provider-btn'); + const uninstallBtn = document.getElementById('uninstall-local-image-provider-btn'); + if (startBtn) startBtn.disabled = !install.installation_complete || result.reachable || !result.enabled; + if (stopBtn) stopBtn.disabled = !result.reachable; + if (uninstallBtn) uninstallBtn.disabled = !install.checks?.comfyui?.ok && !install.checks?.model?.ok; +} + +async function setLocalFluxEnabled(enabled) { + const toggle = document.getElementById('setting-ai-model-photo-local-enabled'); + const status = document.getElementById('local-image-provider-status'); + if (toggle) toggle.disabled = true; + try { + await api('PUT', '/api/settings', { ai_model_photo_local_enabled: enabled ? '1' : '0' }); + if (status) status.textContent = enabled ? 'Lokales FLUX wurde aktiviert.' : 'Lokales FLUX wurde deaktiviert.'; + if (!enabled) { + try { await api('POST', '/api/local-image/stop', {}); } catch {} + } else { + const current = await api('GET', '/api/local-image/status'); + if (current.install?.installation_complete && !current.reachable) { + try { await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); } catch {} + await new Promise(resolve => setTimeout(resolve, 3500)); + } + } + await testLocalImageProvider(); + } catch (err) { + if (toggle) toggle.checked = !enabled; + if (status) status.textContent = `Aktivierung konnte nicht geändert werden: ${err.message}`; + } finally { + if (toggle) toggle.disabled = false; + } +} + +async function uninstallLocalImageProvider() { + const button = document.getElementById('uninstall-local-image-provider-btn'); + const status = document.getElementById('local-image-provider-status'); + const confirmed = window.confirm('Lokales FLUX wirklich deinstallieren? ComfyUI und das große FLUX-Modell werden entfernt. Deine Vendoo-Daten und heruntergeladenen Installationsarchive bleiben erhalten.'); + if (!confirmed) return; + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/uninstall', { remove_downloads: false }); + if (status) status.textContent = result.message || 'Deinstallation wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 2500)); + const started = Date.now(); + while (Date.now() - started < 120000) { + const install = await getDetailedLocalFluxInstallStatus(); + if (install && !install.checks?.comfyui?.ok && !install.checks?.model?.ok) break; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + const toggle = document.getElementById('setting-ai-model-photo-local-enabled'); + if (toggle) toggle.checked = false; + await testLocalImageProvider(); + toast('Lokales FLUX wurde deinstalliert.'); + } catch (err) { + if (status) status.textContent = `Deinstallation fehlgeschlagen: ${err.message}`; + } finally { + if (button) button.disabled = false; + } +} + +function setupSettingsNavigation() { + const buttons = [...document.querySelectorAll('[data-settings-panel]')]; + const panels = [...document.querySelectorAll('[data-settings-panel-content]')]; + const activate = (name) => { + buttons.forEach(button => button.classList.toggle('active', button.dataset.settingsPanel === name)); + panels.forEach(panel => panel.classList.toggle('active', panel.dataset.settingsPanelContent === name)); + try { localStorage.setItem('vendoo-settings-panel', name); } catch {} + }; + buttons.forEach(button => button.addEventListener('click', () => activate(button.dataset.settingsPanel))); + let initial = 'general'; + try { initial = localStorage.getItem('vendoo-settings-panel') || initial; } catch {} + if (!panels.some(panel => panel.dataset.settingsPanelContent === initial)) initial = 'general'; + activate(initial); +} + +function updateReferenceStrengthUi() { + const range = document.getElementById('setting-ai-model-photo-reference-strength'); + const output = document.getElementById('setting-ai-model-photo-reference-strength-value'); + if (range && output) output.textContent = `${range.value}%`; + const referenceHealth = document.getElementById('local-flux-health-reference'); + if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : `${range?.value || 70}%`; +} + +async function testLocalImageProvider(options = {}) { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('test-local-image-provider-btn'); + if (button) button.disabled = true; + if (status) status.textContent = 'Lokaler FLUX/ComfyUI-Status wird geprüft ...'; + try { + const result = await api('GET', '/api/local-image/status'); + const install = options.skipInstallFetch ? (result.install || {}) : (await getDetailedLocalFluxInstallStatus() || result.install || {}); + if (options.skipInstallFetch) renderLocalFluxInstallState(install); + updateLocalFluxCommandCenter(result, install); + if (status) { + if (install.status === 'failed') { + status.textContent = `Installation fehlgeschlagen · ${install.message || 'Details im Installationsstatus'}`; + } else if (result.reachable) { + status.textContent = `Aktiv · ${result.url} erreichbar · Modell und Workflow bereit${result.reference_image_supported ? ' · Produktreferenz unterstützt' : ''}`; + } else { + status.textContent = install.installation_complete ? 'Installiert, aber der Bildserver ist gestoppt.' : `Noch nicht vollständig installiert · ${install.phase_label || 'Installation erforderlich'}`; + } + } + return { ...result, install }; + } catch (err) { + if (status) status.textContent = `Lokaler FLUX-Test fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function installLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('install-local-image-provider-btn'); + const installModel = document.getElementById('local-flux-install-model')?.checked !== false; + const enableAutostart = document.getElementById('local-flux-enable-autostart')?.checked !== false; + const startNow = document.getElementById('local-flux-start-now')?.checked !== false; + const warning = installModel + ? 'ComfyUI und FLUX.1-schnell FP8 werden heruntergeladen. Das benötigt viel Speicherplatz und kann lange dauern. Installation starten?' + : 'ComfyUI wird installiert, das FLUX-Modell jedoch nicht. Installation starten?'; + if (!window.confirm(warning)) return null; + if (button) button.disabled = true; + if (status) status.textContent = 'Lokale FLUX-Installation wird gestartet ...'; + try { + const startedAt = Date.now(); + const result = await api('POST', '/api/local-image/install', { + install_model: installModel, + enable_autostart: enableAutostart, + start_now: startNow, + gpu: 'auto', + }); + if (status) status.textContent = result.message || 'Lokale FLUX-Installation wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 900)); + await pollLocalFluxInstall(startedAt); + return result; + } catch (err) { + if (status) status.textContent = `Lokale FLUX-Installation fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function updateLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('update-local-image-provider-btn'); + if (!window.confirm('Lokales ComfyUI jetzt aktualisieren? Bestehende Modelle bleiben erhalten.')) return null; + if (button) button.disabled = true; + try { + const startedAt = Date.now(); + const result = await api('POST', '/api/local-image/update', {}); + if (status) status.textContent = result.message || 'Lokales FLUX-Update wurde gestartet.'; + await new Promise(resolve => setTimeout(resolve, 900)); + await pollLocalFluxInstall(startedAt); + return result; + } catch (err) { + if (status) status.textContent = `Update fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function startLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('start-local-image-provider-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); + if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird gestartet.'; + await waitForFluxEngineReady({ statusElement: status }); + return testLocalImageProvider(); + } catch (err) { + if (status) status.textContent = `Start fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function stopLocalImageProvider() { + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('stop-local-image-provider-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/stop', {}); + if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird beendet.'; + await new Promise(resolve => setTimeout(resolve, 1800)); + return testLocalImageProvider(); + } catch (err) { + if (status) status.textContent = `Stoppen fehlgeschlagen: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +async function regenerateLocalImageToken() { + const field = document.getElementById('setting-ai-model-photo-local-token'); + const status = document.getElementById('local-image-provider-status'); + const button = document.getElementById('regenerate-local-image-token-btn'); + if (button) button.disabled = true; + try { + const result = await api('POST', '/api/local-image/regenerate-token', {}); + if (field) field.value = result.token || ''; + if (status) status.textContent = 'Neuer lokaler Zugriffstoken erzeugt. Bitte Einstellungen speichern.'; + return result; + } catch (err) { + if (status) status.textContent = `Token konnte nicht erneuert werden: ${err.message}`; + return null; + } finally { + if (button) button.disabled = false; + } +} + +function updateAiPhotoProviderSettingsUi() { + const provider = document.getElementById('setting-ai-model-photo-provider')?.value || 'auto'; + const openrouterSelect = document.getElementById('setting-ai-model-photo-openrouter-model'); + const refreshBtn = document.getElementById('refresh-openrouter-models-btn'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only'); + const openAiField = document.getElementById('setting-ai-model-photo-openai-model'); + const localUrlField = document.getElementById('setting-ai-model-photo-local-url'); + const localTokenField = document.getElementById('setting-ai-model-photo-local-token'); + const localWorkflowField = document.getElementById('setting-ai-model-photo-local-workflow-path'); + const localInstallField = document.getElementById('setting-ai-model-photo-local-install-path'); + const localEnabled = document.getElementById('setting-ai-model-photo-local-enabled'); + const localRequireToken = document.getElementById('setting-ai-model-photo-local-require-token'); + const testBtn = document.getElementById('test-local-image-provider-btn'); + const installBtn = document.getElementById('install-local-image-provider-btn'); + const updateBtn = document.getElementById('update-local-image-provider-btn'); + const startBtn = document.getElementById('start-local-image-provider-btn'); + const stopBtn = document.getElementById('stop-local-image-provider-btn'); + const regenBtn = document.getElementById('regenerate-local-image-token-btn'); + const useLocal = provider === 'auto' || provider === 'local'; + const useOpenRouter = provider !== 'openai' && provider !== 'local'; + const useOpenAi = provider !== 'openrouter' && provider !== 'local'; + if (openrouterSelect) openrouterSelect.disabled = !useOpenRouter; + if (refreshBtn) refreshBtn.disabled = !useOpenRouter; + if (freeOnly) freeOnly.disabled = !useOpenRouter; + if (openAiField) openAiField.disabled = !useOpenAi; + if (localUrlField) localUrlField.disabled = !useLocal; + if (localTokenField) localTokenField.disabled = !useLocal; + if (localWorkflowField) localWorkflowField.disabled = !useLocal; + if (localInstallField) localInstallField.disabled = !useLocal; + if (localEnabled) localEnabled.disabled = false; + if (localRequireToken) localRequireToken.disabled = !useLocal; + if (testBtn) testBtn.disabled = false; + if (installBtn) installBtn.disabled = false; + if (updateBtn) updateBtn.disabled = false; + // Start/Stop werden ausschließlich vom realen Runtime-Status gesteuert. + if (regenBtn) regenBtn.disabled = false; +} + +function renderOpenRouterModelPreview(models = [], selectedId = '', freeOnly = true) { + const wrap = document.getElementById('openrouter-models-preview'); + const status = document.getElementById('openrouter-models-status'); + const select = document.getElementById('setting-ai-model-photo-openrouter-model'); + if (!wrap || !status || !select) return; + + const normalized = Array.isArray(models) ? models : []; + const options = ['']; + normalized.forEach(model => { + const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`; + options.push(``); + }); + select.innerHTML = options.join(''); + if (selectedId) select.value = selectedId; + if (!select.value) select.value = ''; + + if (!normalized.length) { + wrap.classList.add('hidden'); + wrap.innerHTML = ''; + status.textContent = freeOnly + ? 'Keine kostenlosen OpenRouter-Bildmodelle gefunden. In Auto nutzt Vendoo dann OpenAI als Fallback.' + : 'Keine OpenRouter-Bildmodelle gefunden.'; + return; + } + + const top = normalized.slice(0, 4); + wrap.classList.remove('hidden'); + wrap.innerHTML = top.map(model => ` +
                                                                      +
                                                                      +
                                                                      + ${escapeHtml(model.name || model.id || 'OpenRouter Modell')} + ${escapeHtml(model.id || '')} +
                                                                      +
                                                                      + ${model.is_free ? 'Free' : 'Paid'} + Image +
                                                                      +
                                                                      +

                                                                      ${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}

                                                                      +
                                                                      + `).join(''); + status.textContent = `${normalized.length} OpenRouter-Bildmodell(e) verfügbar${freeOnly ? ' (Free gefiltert)' : ''}.`; +} + +async function loadOpenRouterImageModels(selectedId = '') { + const status = document.getElementById('openrouter-models-status'); + const freeOnly = document.getElementById('setting-ai-model-photo-free-only')?.checked !== false; + if (status) status.textContent = 'OpenRouter-Bildmodelle werden geladen ...'; + try { + const result = await api('GET', `/api/ai-model-photos/providers/openrouter/models?free_only=${freeOnly ? '1' : '0'}`); + state.openRouterImageModels = result.models || []; + renderOpenRouterModelPreview(state.openRouterImageModels, selectedId, !!result.free_only); + if (freeOnly && state.openRouterImageModels.length === 0) { + try { + const diagnostics = await api('GET', '/api/ai-model-photos/providers/openrouter/diagnostics'); + if (status) { + status.textContent = diagnostics.free_image_model_count > 0 + ? `${diagnostics.free_image_model_count} Free-Bildmodell(e) erkannt, aber aktuell nicht nutzbar.` + : `Aktuell 0 kostenlose Bildmodelle bei OpenRouter; ${diagnostics.image_model_count || 0} kostenpflichtige Bildmodelle erkannt. Auto nutzt OpenAI als Fallback, sofern ein OpenAI-Key hinterlegt ist.`; + } + } catch {} + } + } catch (err) { + state.openRouterImageModels = []; + renderOpenRouterModelPreview([], selectedId, freeOnly); + if (status) status.textContent = `OpenRouter-Modelle konnten nicht geladen werden: ${err.message}`; + } +} + +function populateSettingsForm(settings, platforms, providers) { + const ps = document.getElementById('setting-default-platform'); + const fs = document.getElementById('filter-platform'); + const as = document.getElementById('setting-default-ai'); + + ps.innerHTML = ''; fs.innerHTML = ''; + for (const p of platforms) { + ps.innerHTML += ``; + fs.innerHTML += ``; + } + as.innerHTML = providers.map(p => ``).join(''); + + ps.value = settings.default_platform || 'vinted'; + as.value = settings.default_ai || 'claude'; + document.getElementById('setting-default-language').value = settings.default_language || 'de'; + document.getElementById('setting-seller-notes').value = settings.seller_notes || ''; + document.getElementById('setting-anthropic-key').placeholder = settings.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = settings.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = settings.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = settings.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ebay-client-id').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + document.getElementById('setting-ebay-sandbox').checked = !!settings.ebay_sandbox; + document.getElementById('setting-ollama-url').value = settings.ollama_url || 'http://localhost:11434'; + document.getElementById('setting-public-base-url').value = settings.public_base_url || ''; + detectPublicBaseUrl({ apply: !settings.public_base_url }); + document.getElementById('setting-ai-model-photo-provider').value = settings.ai_model_photo_provider || 'auto'; + const savedPhotoMode = ['ghost_mannequin', 'flatlay'].includes(settings.ai_model_photo_default_mode) ? settings.ai_model_photo_default_mode : 'flatlay'; + document.getElementById('setting-ai-model-photo-mode').value = savedPhotoMode; + document.getElementById('setting-ai-model-photo-variants').value = String(settings.ai_model_photo_default_variants || '1'); + document.getElementById('setting-ai-model-photo-preset').value = settings.ai_model_photo_default_preset || 'mixed'; + document.getElementById('setting-ai-model-photo-enabled').checked = String(settings.ai_model_photos_enabled || '1') === '1'; + document.getElementById('setting-ai-model-photo-free-only').checked = String(settings.ai_model_photo_openrouter_free_only || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-underwear').checked = String(settings.ai_model_photo_block_underwear || '1') === '1'; + document.getElementById('setting-ai-model-photo-block-kids').checked = String(settings.ai_model_photo_block_kids || '1') === '1'; + document.getElementById('setting-ai-model-photo-preserve-logos').checked = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + getAiPromptDraft('generator').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + getAiPromptDraft('detail').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1'; + document.getElementById('setting-ai-model-photo-openai-model').value = settings.ai_model_photo_openai_model || 'gpt-image-1'; + document.getElementById('setting-ai-model-photo-local-url').value = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + document.getElementById('setting-ai-model-photo-local-token').placeholder = settings.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)'; + document.getElementById('setting-ai-model-photo-local-workflow-path').value = settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + document.getElementById('setting-ai-model-photo-local-install-path').value = settings.ai_model_photo_local_install_path || 'local-ai/comfyui'; + document.getElementById('setting-ai-model-photo-local-enabled').checked = String(settings.ai_model_photo_local_enabled || '1') === '1'; + document.getElementById('setting-ai-model-photo-local-require-token').checked = String(settings.ai_model_photo_local_require_token || '1') === '1'; + document.getElementById('setting-ai-model-photo-use-source-reference').checked = String(settings.ai_model_photo_use_source_reference || '1') === '1'; + document.getElementById('setting-ai-model-photo-reference-strength').value = String(settings.ai_model_photo_reference_strength || '70'); + document.getElementById('setting-ai-model-photo-local-resolution').value = String(settings.ai_model_photo_local_resolution || '768'); + document.getElementById('setting-flux-runtime-profile').value = settings.flux_runtime_profile || 'auto'; + document.getElementById('setting-flux-default-profile').value = settings.flux_default_profile || 'balanced'; + document.getElementById('setting-flux-default-style').value = settings.flux_default_style || 'photorealistic'; + document.getElementById('setting-flux-default-format').value = settings.flux_default_format || '768x768'; + document.getElementById('setting-flux-default-variants').value = settings.flux_default_variants || '1'; + document.getElementById('setting-flux-max-parallel-jobs').value = settings.flux_max_parallel_jobs || '1'; + document.getElementById('setting-flux-prompt-assistant').checked = String(settings.flux_prompt_assistant || '1') === '1'; + document.getElementById('setting-flux-seed-lock').checked = String(settings.flux_seed_lock || '0') === '1'; + const studioStyle = document.getElementById('flux-studio-style'); if (studioStyle) studioStyle.value = settings.flux_default_style || 'photorealistic'; + const studioFormat = document.getElementById('flux-studio-format'); if (studioFormat) studioFormat.value = settings.flux_default_format || '768x768'; + const studioLock = document.getElementById('flux-studio-seed-lock'); if (studioLock) studioLock.checked = String(settings.flux_seed_lock || '0') === '1'; + applyFluxProfile(settings.flux_default_profile || 'balanced', { persist: false }); + setFluxVariantCount(settings.flux_default_variants || '1', { persist: false }); + updateReferenceStrengthUi(); + const aiStatusText = document.getElementById('settings-ai-status-text'); + const aiStatusChip = document.getElementById('settings-ai-status-chip'); + if (aiStatusText) aiStatusText.textContent = String(settings.ai_model_photos_enabled || '1') === '1' ? 'Aktiv' : 'Deaktiviert'; + if (aiStatusChip) aiStatusChip.className = `settings-status-chip ${String(settings.ai_model_photos_enabled || '1') === '1' ? 'is-running' : 'is-disabled'}`; + updateAiPhotoProviderSettingsUi(); + loadOpenRouterImageModels(settings.ai_model_photo_openrouter_model || ''); + testLocalImageProvider(); + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + const etsyBadge = document.getElementById('etsy-status-badge'); + if (settings.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + if (settings.etsy_connected) { + etsyBadge.textContent = 'Verbunden'; + etsyBadge.className = 'etsy-status connected'; + } else { + etsyBadge.textContent = 'Nicht verbunden'; + etsyBadge.className = 'etsy-status disconnected'; + } + } else { + etsyWrap.classList.add('hidden'); + } + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + const ebayBadge = document.getElementById('ebay-status-badge'); + if (settings.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + if (settings.ebay_connected) { + ebayBadge.textContent = settings.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebayBadge.className = 'etsy-status connected'; + } else { + ebayBadge.textContent = 'Nicht verbunden'; + ebayBadge.className = 'etsy-status disconnected'; + } + } else { + ebayWrap.classList.add('hidden'); + } +} + +function setupSettings() { + setupSettingsNavigation(); + updateReferenceStrengthUi(); + watchLocalFluxInstallStatus(); + document.getElementById('detect-public-base-url-btn')?.addEventListener('click', () => detectPublicBaseUrl({ apply: true })); + document.getElementById('setting-ai-model-photo-provider')?.addEventListener('change', updateAiPhotoProviderSettingsUi); + document.getElementById('setting-ai-model-photo-free-only')?.addEventListener('change', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + document.getElementById('refresh-openrouter-models-btn')?.addEventListener('click', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || '')); + document.getElementById('test-local-image-provider-btn')?.addEventListener('click', testLocalImageProvider); + document.getElementById('install-local-image-provider-btn')?.addEventListener('click', installLocalImageProvider); + document.getElementById('update-local-image-provider-btn')?.addEventListener('click', updateLocalImageProvider); + document.getElementById('start-local-image-provider-btn')?.addEventListener('click', startLocalImageProvider); + document.getElementById('stop-local-image-provider-btn')?.addEventListener('click', stopLocalImageProvider); + document.getElementById('regenerate-local-image-token-btn')?.addEventListener('click', regenerateLocalImageToken); + document.getElementById('uninstall-local-image-provider-btn')?.addEventListener('click', uninstallLocalImageProvider); + document.getElementById('setting-ai-model-photo-local-enabled')?.addEventListener('change', (event) => setLocalFluxEnabled(event.target.checked)); + document.getElementById('setting-ai-model-photo-reference-strength')?.addEventListener('input', updateReferenceStrengthUi); + document.getElementById('setting-ai-model-photo-use-source-reference')?.addEventListener('change', updateReferenceStrengthUi); + document.getElementById('setting-ai-model-photo-enabled')?.addEventListener('change', (event) => { + const text = document.getElementById('settings-ai-status-text'); + const chip = document.getElementById('settings-ai-status-chip'); + if (text) text.textContent = event.target.checked ? 'Aktiv' : 'Deaktiviert'; + if (chip) chip.className = `settings-status-chip ${event.target.checked ? 'is-running' : 'is-disabled'}`; + }); + document.querySelectorAll('#settings input, #settings select, #settings textarea').forEach(field => { + if (field.closest('#theme-manager-root')) return; + field.addEventListener('change', () => { + const title = document.getElementById('settings-save-title'); + const copy = document.getElementById('settings-save-copy'); + if (title) title.textContent = 'Ungespeicherte Änderungen'; + if (copy) copy.textContent = 'Mit „Einstellungen speichern“ übernehmen.'; + }); + }); + + document.getElementById('save-settings-btn').addEventListener('click', async () => { + try { + const settings = { + default_platform: document.getElementById('setting-default-platform').value, + default_ai: document.getElementById('setting-default-ai').value, + default_language: document.getElementById('setting-default-language').value, + seller_notes: document.getElementById('setting-seller-notes').value, + ollama_url: document.getElementById('setting-ollama-url').value, + public_base_url: document.getElementById('setting-public-base-url').value.trim(), + ai_model_photos_enabled: document.getElementById('setting-ai-model-photo-enabled').checked ? '1' : '0', + ai_model_photo_provider: document.getElementById('setting-ai-model-photo-provider').value, + ai_model_photo_default_mode: document.getElementById('setting-ai-model-photo-mode').value, + ai_model_photo_default_variants: document.getElementById('setting-ai-model-photo-variants').value, + ai_model_photo_default_preset: document.getElementById('setting-ai-model-photo-preset').value, + ai_model_photo_openrouter_free_only: document.getElementById('setting-ai-model-photo-free-only').checked ? '1' : '0', + ai_model_photo_block_underwear: document.getElementById('setting-ai-model-photo-block-underwear').checked ? '1' : '0', + ai_model_photo_block_kids: document.getElementById('setting-ai-model-photo-block-kids').checked ? '1' : '0', + ai_model_photo_preserve_logos: document.getElementById('setting-ai-model-photo-preserve-logos').checked ? '1' : '0', + ai_model_photo_openrouter_model: document.getElementById('setting-ai-model-photo-openrouter-model').value, + ai_model_photo_openai_model: document.getElementById('setting-ai-model-photo-openai-model').value.trim() || 'gpt-image-1', + ai_model_photo_local_enabled: document.getElementById('setting-ai-model-photo-local-enabled').checked ? '1' : '0', + ai_model_photo_local_url: document.getElementById('setting-ai-model-photo-local-url').value.trim() || 'http://127.0.0.1:8188', + ai_model_photo_local_workflow_path: document.getElementById('setting-ai-model-photo-local-workflow-path').value.trim() || 'local-ai/workflows/vendoo_flux_schnell_api.json', + ai_model_photo_local_install_path: document.getElementById('setting-ai-model-photo-local-install-path').value.trim() || 'local-ai/comfyui', + ai_model_photo_local_require_token: document.getElementById('setting-ai-model-photo-local-require-token').checked ? '1' : '0', + ai_model_photo_use_source_reference: document.getElementById('setting-ai-model-photo-use-source-reference').checked ? '1' : '0', + ai_model_photo_reference_strength: document.getElementById('setting-ai-model-photo-reference-strength').value || '70', + ai_model_photo_local_resolution: document.getElementById('setting-ai-model-photo-local-resolution').value || '768', + flux_runtime_profile: document.getElementById('setting-flux-runtime-profile').value || 'auto', + flux_default_profile: document.getElementById('setting-flux-default-profile').value || 'balanced', + flux_prompt_assistant: document.getElementById('setting-flux-prompt-assistant').checked ? '1' : '0', + flux_default_style: document.getElementById('setting-flux-default-style').value || 'photorealistic', + flux_default_format: document.getElementById('setting-flux-default-format').value || '768x768', + flux_default_variants: document.getElementById('setting-flux-default-variants').value || '1', + flux_seed_lock: document.getElementById('setting-flux-seed-lock').checked ? '1' : '0', + flux_max_parallel_jobs: document.getElementById('setting-flux-max-parallel-jobs').value || '1', + }; + const ak = document.getElementById('setting-anthropic-key').value; + const ok = document.getElementById('setting-openai-key').value; + const ork = document.getElementById('setting-openrouter-key').value; + const ek = document.getElementById('setting-etsy-key').value; + const localToken = document.getElementById('setting-ai-model-photo-local-token').value.trim(); + const ebayId = document.getElementById('setting-ebay-client-id').value; + const ebaySec = document.getElementById('setting-ebay-client-secret').value; + const ebayRu = document.getElementById('setting-ebay-ru-name').value; + const ebaySandbox = document.getElementById('setting-ebay-sandbox').checked; + if (ak) settings.anthropic_key = ak; + if (ok) settings.openai_key = ok; + if (ork) settings.openrouter_key = ork; + if (ek) settings.etsy_key = ek; + if (localToken) settings.ai_model_photo_local_token = localToken; + if (ebayId) settings.ebay_client_id = ebayId; + if (ebaySec) settings.ebay_client_secret = ebaySec; + if (ebayRu) settings.ebay_ru_name = ebayRu; + settings.ebay_sandbox = ebaySandbox ? 'true' : 'false'; + + const saved = await api('PUT', '/api/settings', settings); + state.platform = settings.default_platform; + state.aiProvider = settings.default_ai; + state.language = settings.default_language; + renderPlatformButtons(); + renderProviderButtons(); + document.getElementById('language-select').value = state.language; + + document.getElementById('setting-anthropic-key').value = ''; + document.getElementById('setting-openai-key').value = ''; + document.getElementById('setting-openrouter-key').value = ''; + document.getElementById('setting-etsy-key').value = ''; + document.getElementById('setting-ai-model-photo-local-token').value = ''; + document.getElementById('setting-ebay-client-id').value = ''; + document.getElementById('setting-ebay-client-secret').value = ''; + document.getElementById('setting-anthropic-key').placeholder = saved.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...'; + document.getElementById('setting-openai-key').placeholder = saved.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...'; + document.getElementById('setting-openrouter-key').placeholder = saved.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...'; + document.getElementById('setting-etsy-key').placeholder = saved.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx'; + document.getElementById('setting-ai-model-photo-local-token').placeholder = saved.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)'; + document.getElementById('setting-ebay-client-id').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...'; + document.getElementById('setting-ebay-client-secret').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...'; + + const etsyWrap = document.getElementById('etsy-connect-wrap'); + if (saved.has_etsy_key) { + etsyWrap.classList.remove('hidden'); + const badge = document.getElementById('etsy-status-badge'); + if (saved.etsy_connected) { badge.textContent = 'Verbunden'; badge.className = 'etsy-status connected'; } + else { badge.textContent = 'Nicht verbunden'; badge.className = 'etsy-status disconnected'; } + } + + updateAiPhotoProviderSettingsUi(); + if (saved.public_base_url !== undefined) document.getElementById('setting-public-base-url').value = saved.public_base_url || ''; + detectPublicBaseUrl({ apply: false }); + loadOpenRouterImageModels(saved.ai_model_photo_openrouter_model || document.getElementById('setting-ai-model-photo-openrouter-model').value || ''); + testLocalImageProvider(); + + const ebayWrap = document.getElementById('ebay-connect-wrap'); + if (saved.has_ebay_credentials) { + ebayWrap.classList.remove('hidden'); + const ebBadge = document.getElementById('ebay-status-badge'); + if (saved.ebay_connected) { + ebBadge.textContent = saved.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden'; + ebBadge.className = 'etsy-status connected'; + } else { + ebBadge.textContent = 'Nicht verbunden'; + ebBadge.className = 'etsy-status disconnected'; + } + } + const saveTitle = document.getElementById('settings-save-title'); + const saveCopy = document.getElementById('settings-save-copy'); + if (saveTitle) saveTitle.textContent = 'Einstellungen gespeichert'; + if (saveCopy) saveCopy.textContent = `Zuletzt gespeichert: ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + toast('Einstellungen gespeichert!'); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('etsy-connect-btn').addEventListener('click', () => { + window.open('/auth/etsy', 'etsy-auth', 'width=600,height=700'); + }); + document.getElementById('ebay-connect-btn').addEventListener('click', () => { + window.open('/auth/ebay', 'ebay-auth', 'width=600,height=700'); + }); + window.addEventListener('message', async (e) => { + if (e.data === 'etsy-connected') { + const badge = document.getElementById('etsy-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('Etsy erfolgreich verbunden!'); + } + if (e.data === 'ebay-connected') { + const badge = document.getElementById('ebay-status-badge'); + badge.textContent = 'Verbunden'; + badge.className = 'etsy-status connected'; + toast('eBay erfolgreich verbunden!'); + } + }); + + document.querySelectorAll('.toggle-visibility').forEach(btn => { + btn.addEventListener('click', () => { + const inp = btn.previousElementSibling; + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + btn.textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + }); +} + +// --- Category Picker --- +const catCache = {}; + +async function loadCategories(platformId) { + if (catCache[platformId]) return catCache[platformId]; + try { + const data = await api('GET', `/api/categories/${platformId}`); + catCache[platformId] = data; + return data; + } catch { return { tree: [], flat: [] }; } +} + +function createCategoryPicker(container, platformId, initialValue) { + const wrap = typeof container === 'string' ? document.getElementById(container) : container; + if (!wrap) return { getValue: () => null, setValue: () => {} }; + + let selected = null; + let dropdownOpen = false; + + wrap.innerHTML = ` +
                                                                      + +
                                                                      + +
                                                                      `; + + const input = wrap.querySelector('.cat-search'); + const dropdown = wrap.querySelector('.cat-dropdown'); + const selectedDisplay = wrap.querySelector('.cat-selected-display'); + + async function doSearch(q) { + const data = await loadCategories(platformId); + if (!q || q.length < 2) { + renderTree(data.tree); + return; + } + try { + const results = await api('GET', `/api/categories/${platformId}/match?q=${encodeURIComponent(q)}`); + renderResults(results); + } catch { renderResults([]); } + } + + function renderResults(results) { + if (!results.length) { + dropdown.innerHTML = '
                                                                      Keine Kategorien gefunden
                                                                      '; + openDropdown(); + return; + } + dropdown.innerHTML = results.map(r => ` +
                                                                      +
                                                                      ${escapeHtml(r.name)}
                                                                      +
                                                                      ${escapeHtml(r.pathStr)}
                                                                      +
                                                                      `).join(''); + dropdown.querySelectorAll('.cat-result-item').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function renderTree(tree) { + if (!tree || !tree.length) { + dropdown.innerHTML = '
                                                                      Keine Kategorien verfügbar
                                                                      '; + openDropdown(); + return; + } + dropdown.innerHTML = tree.map(group => { + const children = (group.children || []).map(sub => { + if (sub.children) { + const leaves = sub.children.map(leaf => + `
                                                                      ${escapeHtml(leaf.name)}
                                                                      ` + ).join(''); + return `
                                                                      +
                                                                      ${escapeHtml(sub.name)}
                                                                      +
                                                                      ${leaves}
                                                                      +
                                                                      `; + } + return `
                                                                      ${escapeHtml(sub.name)}
                                                                      `; + }).join(''); + return `
                                                                      +
                                                                      ${escapeHtml(group.name)}
                                                                      + +
                                                                      `; + }).join(''); + + dropdown.querySelectorAll('.cat-group-hd').forEach(hd => { + hd.addEventListener('click', () => { + const body = hd.nextElementSibling; + const expanded = hd.dataset.expanded === 'true'; + hd.dataset.expanded = expanded ? 'false' : 'true'; + body.style.display = expanded ? 'none' : 'block'; + hd.classList.toggle('expanded', !expanded); + }); + }); + + dropdown.querySelectorAll('.cat-leaf').forEach(el => { + el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name)); + }); + openDropdown(); + } + + function selectCategory(path, name) { + selected = path; + input.value = ''; + input.style.display = 'none'; + selectedDisplay.style.display = 'flex'; + selectedDisplay.innerHTML = ` + ${escapeHtml(path)} + `; + selectedDisplay.querySelector('.cat-sel-clear').addEventListener('click', clearSelection); + closeDropdown(); + } + + function clearSelection() { + selected = null; + input.style.display = ''; + selectedDisplay.style.display = 'none'; + input.value = ''; + input.focus(); + } + + function openDropdown() { dropdown.style.display = 'block'; dropdownOpen = true; } + function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; } + + input.addEventListener('focus', () => doSearch(input.value)); + input.addEventListener('input', debounce(() => doSearch(input.value), 200)); + + document.addEventListener('click', e => { + if (dropdownOpen && !wrap.contains(e.target)) closeDropdown(); + }); + + if (initialValue) { + selectCategory(initialValue, initialValue.split(' > ').pop()); + } + + return { + getValue: () => selected, + setValue: (val) => { + if (val) selectCategory(val, val.split(' > ').pop()); + else clearSelection(); + }, + }; +} + +let metaCategoryPicker = null; + +function addQuillTooltips() { + const tooltips = { + 'ql-bold': 'Fett', 'ql-italic': 'Kursiv', 'ql-underline': 'Unterstrichen', + 'ql-strike': 'Durchgestrichen', 'ql-blockquote': 'Zitat', 'ql-link': 'Link einfügen', + 'ql-clean': 'Formatierung entfernen', 'ql-list[value="ordered"]': 'Nummerierte Liste', + 'ql-list[value="bullet"]': 'Aufzählung', 'ql-color': 'Textfarbe', + 'ql-background': 'Hintergrundfarbe', 'ql-align': 'Ausrichtung', 'ql-header': 'Überschrift', + }; + for (const [sel, tip] of Object.entries(tooltips)) { + document.querySelectorAll(`.ql-${sel}, .${sel}, button.${sel.split('[')[0]}`).forEach(el => { el.title = tip; }); + } + document.querySelectorAll('.ql-bold').forEach(el => { el.title = 'Fett'; }); + document.querySelectorAll('.ql-italic').forEach(el => { el.title = 'Kursiv'; }); + document.querySelectorAll('.ql-underline').forEach(el => { el.title = 'Unterstrichen'; }); + document.querySelectorAll('.ql-strike').forEach(el => { el.title = 'Durchgestrichen'; }); + document.querySelectorAll('.ql-blockquote').forEach(el => { el.title = 'Zitat'; }); + document.querySelectorAll('.ql-link').forEach(el => { el.title = 'Link einfügen'; }); + document.querySelectorAll('.ql-clean').forEach(el => { el.title = 'Formatierung entfernen'; }); + document.querySelectorAll('.ql-list[value="ordered"]').forEach(el => { el.title = 'Nummerierte Liste'; }); + document.querySelectorAll('.ql-list[value="bullet"]').forEach(el => { el.title = 'Aufzählung'; }); + document.querySelectorAll('.ql-color .ql-picker-label').forEach(el => { el.title = 'Textfarbe'; }); + document.querySelectorAll('.ql-background .ql-picker-label').forEach(el => { el.title = 'Hintergrundfarbe'; }); + document.querySelectorAll('.ql-align .ql-picker-label').forEach(el => { el.title = 'Ausrichtung'; }); + document.querySelectorAll('.ql-header .ql-picker-label').forEach(el => { el.title = 'Überschrift'; }); +} + +function generateQRDataUrl(text, size) { + if (typeof QRious === 'undefined') return null; + try { + const qr = new QRious({ value: text, size: size || 120, level: 'M' }); + return qr.toDataURL(); + } catch { return null; } +} + +let printStudioState = { + mode: 'labels', + listings: [], + listing: null, + copies: 1, + format: '90x50', + showPrice: true, +}; + +function ensurePrintStudio() { + let studio = document.getElementById('print-studio'); + if (studio) return studio; + + studio = document.createElement('div'); + studio.id = 'print-studio'; + studio.className = 'print-studio hidden'; + studio.innerHTML = ` + + + `; + document.body.appendChild(studio); + + studio.querySelectorAll('[data-print-close]').forEach(button => { + button.addEventListener('click', closePrintStudio); + }); + studio.querySelector('#print-format-select').addEventListener('change', event => { + printStudioState.format = event.target.value; + renderPrintStudio(); + }); + studio.querySelector('#print-copies-input').addEventListener('input', event => { + printStudioState.copies = Math.max(1, Math.min(20, Number(event.target.value) || 1)); + renderPrintStudio(); + }); + studio.querySelector('#print-show-price').addEventListener('change', event => { + printStudioState.showPrice = event.target.checked; + renderPrintStudio(); + }); + studio.querySelector('#print-now-btn').addEventListener('click', () => { + document.body.classList.add('print-studio-active'); + window.print(); + }); + window.addEventListener('afterprint', () => document.body.classList.remove('print-studio-active')); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !studio.classList.contains('hidden')) closePrintStudio(); + }); + return studio; +} + +function closePrintStudio() { + const studio = document.getElementById('print-studio'); + if (!studio) return; + studio.classList.add('hidden'); + document.body.classList.remove('print-studio-open', 'print-studio-active'); +} + +function openPrintStudio(mode, payload) { + printStudioState = { + mode, + listings: payload.listings || [], + listing: payload.listing || null, + copies: 1, + format: '90x50', + showPrice: true, + }; + const studio = ensurePrintStudio(); + studio.classList.remove('hidden'); + document.body.classList.add('print-studio-open'); + studio.querySelector('#print-format-select').value = printStudioState.format; + studio.querySelector('#print-copies-input').value = '1'; + studio.querySelector('#print-show-price').checked = true; + renderPrintStudio(); +} + +function printLabels(listings) { + if (!Array.isArray(listings) || !listings.length) return; + openPrintStudio('labels', { listings }); +} + +function printDatasheet(listing) { + if (!listing) return; + openPrintStudio('datasheet', { listing }); +} + +function getPrintPlatformName(platform) { + return state.platforms.find(item => item.id === platform)?.name || platformLabel(platform); +} + +function buildPrintLabel(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const title = (listing.title || 'Ohne Titel').substring(0, 60); + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 170); + const price = listing.price ? formatCurrency(listing.price) : ''; + const platform = getPrintPlatformName(listing.platform); + return ` + + `; +} + +function buildPrintDatasheet(listing) { + const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`; + const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 190); + const photos = (listing.photos || []).slice(0, 4); + const dateValue = listing.created_at ? new Date(`${listing.created_at}Z`) : new Date(); + const date = Number.isNaN(dateValue.getTime()) ? '' : dateValue.toLocaleDateString('de-DE'); + const meta = [ + ['Marke', listing.brand], + ['Kategorie', listing.category], + ['Größe', listing.size], + ['Farbe', listing.color], + ['Zustand', listing.condition], + ['Preis', listing.price ? formatCurrency(listing.price) : ''], + ['Plattform', getPrintPlatformName(listing.platform)], + ['Lagerort', listing.storage_location], + ['Erstellt', date], + ].filter(([, value]) => value); + + return ` + + `; +} + +function chunkPrintItems(items, size) { + const chunks = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +function renderPrintStudio() { + const studio = ensurePrintStudio(); + const pages = studio.querySelector('#print-preview-pages'); + const summary = studio.querySelector('#print-mode-summary'); + const count = studio.querySelector('#print-preview-count'); + const formatField = studio.querySelector('#print-format-field'); + const copiesField = studio.querySelector('#print-copies-field'); + const priceField = studio.querySelector('#print-price-field'); + + const labelsMode = printStudioState.mode === 'labels'; + formatField.classList.toggle('hidden', !labelsMode); + copiesField.classList.toggle('hidden', !labelsMode); + priceField.classList.toggle('hidden', !labelsMode); + + if (!labelsMode) { + const listing = printStudioState.listing; + summary.innerHTML = `
                                                                      Datenblatt${escapeHtml(listing?.sku || listing?.title || 'Artikel')}
                                                                      `; + count.textContent = '1 Seite'; + pages.innerHTML = ``; + return; + } + + const expanded = []; + printStudioState.listings.forEach(listing => { + for (let copy = 0; copy < printStudioState.copies; copy += 1) expanded.push(listing); + }); + const compact = printStudioState.format === '62x29'; + const pageSize = compact ? 27 : 10; + const pageChunks = chunkPrintItems(expanded, pageSize); + summary.innerHTML = `
                                                                      Etiketten${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten
                                                                      `; + count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`; + pages.innerHTML = pageChunks.map(chunk => ` + + `).join(''); +} + +// --- Trash / Papierkorb --- +function setupTrash() { + document.getElementById('empty-trash-btn').addEventListener('click', async () => { + if (!confirm('Papierkorb endgültig leeren? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', '/api/trash'); + toast('Papierkorb geleert'); + loadTrash(); + updateTrashBadge(); + }); +} + +async function loadTrash() { + try { + const listings = await api('GET', '/api/trash'); + const c = document.getElementById('trash-list'); + if (!listings.length) { + c.innerHTML = '
                                                                      Papierkorb ist leer
                                                                      '; + return; + } + + c.innerHTML = listings.map(l => { + const photo = l.photos?.[0] ? `` : '
                                                                      '; + const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform; + const deletedDate = l.deleted_at ? new Date(l.deleted_at + 'Z').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; + const priceStr = l.price ? `${l.price}€` : ''; + + return `
                                                                      + ${photo} +
                                                                      +
                                                                      ${escapeHtml(l.title || 'Ohne Titel')}
                                                                      +
                                                                      ${pName} · ${priceStr} · Gelöscht: ${deletedDate}
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      `; + }).join(''); + + c.querySelectorAll('.restore-btn').forEach(btn => { + btn.addEventListener('click', async () => { + await api('POST', `/api/trash/${btn.dataset.id}/restore`); + toast('Listing wiederhergestellt'); + loadTrash(); + updateTrashBadge(); + }); + }); + + c.querySelectorAll('.perm-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Listing endgültig löschen? Dies kann nicht rückgängig gemacht werden!')) return; + await api('DELETE', `/api/trash/${btn.dataset.id}`); + toast('Endgültig gelöscht'); + loadTrash(); + updateTrashBadge(); + }); + }); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function updateTrashBadge() { + try { + const data = await api('GET', '/api/trash/count'); + const badge = document.getElementById('trash-badge'); + if (data.count > 0) { + badge.textContent = data.count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } catch {} +} + +// --- Utils --- +function toast(msg) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2500); +} + +function debounce(fn, ms) { + let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; +} + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; +} + +// --- Dark Mode --- +function setupDarkMode() { + const saved = localStorage.getItem('vendoo-theme'); + if (window.VendooTheme) { + window.VendooTheme.setTheme(saved === 'light' || saved === 'dark' || saved === 'contrast' ? saved : 'system', { persist: false }); + } else if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.setAttribute('data-theme', 'dark'); + } + updateThemeIcon(); + + document.getElementById('theme-toggle').addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + const nextTheme = isDark ? 'light' : 'dark'; + if (window.VendooTheme) window.VendooTheme.setTheme(nextTheme); + else { + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem('vendoo-theme', nextTheme); + } + updateThemeIcon(); + }); +} + +function updateThemeIcon() { + const btn = document.getElementById('theme-toggle'); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + btn.title = isDark ? 'Light Mode' : 'Dark Mode'; + btn.innerHTML = isDark + ? '' + : ''; +} + +// --- Dashboard & Studio Flow --- +const STUDIO_STAGES = [ + { key: 'ready', label: 'Bereit', state: 'Prüfbereit', tone: 'success' }, + { key: 'scheduled', label: 'Geplant', state: 'Geplant', tone: 'warning' }, + { key: 'published', label: 'Veröffentlicht', state: 'Live', tone: 'success' }, + { key: 'sold', label: 'Verkauft', state: 'Verkauft', tone: 'success' }, +]; + +let studioWorkflowData = null; +let selectedStudioListingId = null; +let todayFilter = 'all'; +let dashboardAnalyticsData = null; +let dashboardResizeObserver = null; +let dashboardResizeTimer = null; +let dashboardLastChartWidth = 0; + +function formatCurrency(value) { + if (value === null || value === undefined || value === '') return ''; + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value) || 0); +} + +function platformLabel(platform) { + return ({ vinted: 'Vinted', 'ebay-ka': 'Kleinanzeigen', 'ebay-de': 'eBay', etsy: 'Etsy', klekt: 'KLEKT' })[platform] || platform || '—'; +} + +function photoUrl(photo) { + if (!photo) return ''; + return `/uploads/${String(photo).split('/').map(encodeURIComponent).join('/')}`; +} + +function relativeTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const diff = Math.max(0, Date.now() - date.getTime()); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'Gerade eben'; + if (minutes < 60) return `vor ${minutes} Min.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `vor ${hours} Std.`; + const days = Math.floor(hours / 24); + if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`; + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function activityGlyph(type) { + return ({ sold: '✓', published: '↗', failed: '!', scheduled: '◷', copied: '⧉', pending: '·', created: '+' })[type] || '·'; +} + +function setupAnalyticsDashboard() { + document.getElementById('dashboard-period')?.addEventListener('change', loadAnalyticsDashboard); + document.getElementById('dashboard-refresh')?.addEventListener('click', loadAnalyticsDashboard); + + const dashboardPage = document.getElementById('dashboard'); + const redrawDashboard = () => { + if (!dashboardAnalyticsData || !dashboardPage?.classList.contains('active')) return; + clearTimeout(dashboardResizeTimer); + dashboardResizeTimer = setTimeout(() => { + const width = document.getElementById('dashboard-line-chart')?.clientWidth || 0; + if (Math.abs(width - dashboardLastChartWidth) < 12) return; + dashboardLastChartWidth = width; + renderDashboardLineChart(dashboardAnalyticsData.timeline || []); + renderDashboardBars(dashboardAnalyticsData.platformRevenue || []); + renderDashboardCategories(dashboardAnalyticsData.categoryRevenue || []); + }, 90); + }; + if ('ResizeObserver' in window && dashboardPage) { + dashboardResizeObserver?.disconnect(); + dashboardResizeObserver = new ResizeObserver(redrawDashboard); + dashboardResizeObserver.observe(dashboardPage); + } else { + window.addEventListener('resize', redrawDashboard, { passive: true }); + } + + document.querySelectorAll('[data-dashboard-action]').forEach(button => button.addEventListener('click', async () => { + const action = button.dataset.dashboardAction; + if (action === 'new') return switchToTab('generator'); + if (action === 'qr') { + switchToTab('generator'); + setTimeout(() => document.getElementById('generator-mobile-upload')?.click(), 160); + return; + } + if (action === 'publish') return switchToTab('publish'); + if (action === 'template') return switchToTab('templates'); + if (action === 'photos') { + try { + const listings = await api('GET', '/api/listings'); + const photos = listings.flatMap(item => item.photos || []).slice(0, 60); + await downloadPublishPhotos(photos); + } catch (error) { toast(`Fotos konnten nicht geladen werden: ${error.message}`); } + } + })); +} + +function setupTodayFlow() { + document.querySelectorAll('[data-today-filter]').forEach(button => button.addEventListener('click', () => { + todayFilter = button.dataset.todayFilter || 'all'; + document.querySelectorAll('.today-filter-tabs [data-today-filter]').forEach(tab => tab.classList.toggle('active', tab.dataset.todayFilter === todayFilter)); + renderTodayReady(studioWorkflowData); + if (todayFilter === 'attention') document.querySelector('.attention-panel')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + })); + document.getElementById('today-sort')?.addEventListener('change', () => renderTodayReady(studioWorkflowData)); +} + +function dashboardSetText(id, value) { + const element = document.getElementById(id); + if (element) element.textContent = value; +} + +function renderSparkline(id, values, tone = 'brand') { + const svg = document.getElementById(id); + if (!svg) return; + const raw = (values || []).map(Number).filter(Number.isFinite); + const nums = raw.length > 1 ? raw : [raw[0] || 0, raw[0] || 0]; + const max = Math.max(...nums, 1); + const min = Math.min(...nums, 0); + const range = Math.max(1, max - min); + const points = nums.map((value, index) => `${(index / Math.max(1, nums.length - 1)) * 96 + 2},${24 - ((value - min) / range) * 18}`).join(' '); + const last = points.split(' ').at(-1)?.split(',') || ['98', '14']; + svg.setAttribute('viewBox', '0 0 100 28'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.innerHTML = ``; + svg.dataset.tone = tone; +} + +function renderDashboardLineChart(timeline) { + const container = document.getElementById('dashboard-line-chart'); + if (!container) return; + const values = timeline.map(item => Number(item.revenue) || 0); + const max = Math.max(...values, 1); + const measuredWidth = Math.round(container.getBoundingClientRect().width || container.clientWidth || 720); + const width = Math.max(320, measuredWidth); + const height = width < 520 ? 220 : 250; + const left = width < 520 ? 38 : 48; + const right = 14; + const top = 16; + const bottom = 34; + const chartW = width - left - right; + const chartH = height - top - bottom; + const points = values.map((value, index) => { + const x = left + (index / Math.max(1, values.length - 1)) * chartW; + const y = top + chartH - (value / max) * chartH; + return { x, y, value, item: timeline[index] }; + }); + const line = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); + const area = `${left},${top + chartH} ${line} ${left + chartW},${top + chartH}`; + const yGrid = [0, .25, .5, .75, 1].map(part => { + const y = top + chartH - part * chartH; + return `${Math.round(max * part).toLocaleString('de-DE')} €`; + }).join(''); + const step = Math.max(1, Math.floor(timeline.length / 5)); + const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1 + ? `${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}` : '').join(''); + container.innerHTML = `${yGrid}${labels}${points.map(point => `${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}`).join('')}`; + dashboardLastChartWidth = measuredWidth; +} + +function renderDashboardBars(items) { + const container = document.getElementById('dashboard-platform-chart'); + if (!container) return; + const data = items.length ? items.slice(0, 5) : [{ platform: 'Keine Verkäufe', revenue: 0 }]; + const max = Math.max(...data.map(item => Number(item.revenue) || 0), 1); + container.innerHTML = data.map(item => `
                                                                      ${escapeHtml(formatCurrency(item.revenue))}
                                                                      ${escapeHtml(platformLabel(item.platform))}
                                                                      `).join(''); +} + +function renderDashboardCategories(items) { + const container = document.getElementById('dashboard-category-chart'); + if (!container) return; + const data = items.length ? items : [{ category: 'Keine Verkäufe', revenue: 1 }]; + const total = data.reduce((sum, item) => sum + (Number(item.revenue) || 0), 0) || 1; + let cursor = 0; + const colors = ['#49a66f', '#3b82dc', '#8054b3', '#e87334', '#e4ab2f', '#b9b6b0']; + const stops = data.map((item, index) => { + const start = cursor; + cursor += ((Number(item.revenue) || 0) / total) * 100; + return `${colors[index % colors.length]} ${start}% ${cursor}%`; + }).join(','); + container.innerHTML = `
                                                                      ${escapeHtml(formatCurrency(total))}Gesamt
                                                                      ${data.map((item, index) => `
                                                                      ${escapeHtml(item.category)}${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%
                                                                      `).join('')}
                                                                      `; +} + +function renderDashboardFocus(focus) { + const container = document.getElementById('dashboard-focus'); + if (!container) return; + const items = [ + ['ready', focus.ready, 'Artikel bereit zum Veröffentlichen', 'history'], + ['price', focus.priceChecks, 'Preisaktualisierungen empfohlen', 'history'], + ['failed', focus.failedPublishes, 'fehlgeschlagene Publish-Versuche', 'publish'], + ['uploads', focus.mobileUploads, 'neue Handy-Foto-Uploads', 'generator'], + ['locations', focus.activeLocations, 'aktive Lagerorte', 'inventory'], + ]; + container.innerHTML = items.map(([tone, count, label, tab]) => ``).join(''); + container.querySelectorAll('[data-focus-tab]').forEach(button => button.addEventListener('click', () => switchToTab(button.dataset.focusTab))); +} + +function renderDashboardTopSellers(items) { + const body = document.getElementById('dashboard-top-sellers'); + if (!body) return; + body.innerHTML = items.length ? items.map(item => `
                                                                      ${item.photo ? `` : ''}
                                                                      ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))}
                                                                      ${escapeHtml(item.sku || '')}${escapeHtml(platformLabel(item.platform))}${escapeHtml(formatCurrency(item.revenue))}${item.sales || 1}Verkauft`).join('') : 'Noch keine Verkäufe vorhanden.'; + body.querySelectorAll('[data-dashboard-listing]').forEach(row => row.addEventListener('click', () => showDetail(Number(row.dataset.dashboardListing)))); +} + +function renderDashboardFeed(id, items, isNotification = false) { + const container = document.getElementById(id); + if (!container) return; + if (!items?.length) { container.innerHTML = '
                                                                      Keine Einträge
                                                                      '; return; } + container.innerHTML = items.slice(0, 6).map(item => ``).join(''); + container.querySelectorAll('[data-feed-listing]').forEach(button => button.addEventListener('click', () => { const idValue = Number(button.dataset.feedListing); if (idValue) showDetail(idValue); })); +} + +async function loadAnalyticsDashboard() { + const period = Number(document.getElementById('dashboard-period')?.value || 30); + const refresh = document.getElementById('dashboard-refresh'); + refresh?.classList.add('is-loading'); + try { + const data = await api('GET', `/api/dashboard/analytics?days=${period}`); + dashboardAnalyticsData = data; + dashboardSetText('dash-revenue-today', formatCurrency(data.kpis.revenueToday)); + dashboardSetText('dash-sales-week', String(data.kpis.sales7Days)); + dashboardSetText('dash-active-listings', String(data.kpis.activeListings)); + dashboardSetText('dash-drafts', String(data.kpis.drafts)); + dashboardSetText('dash-inventory-value', formatCurrency(data.kpis.inventoryValue)); + dashboardSetText('dash-publish-rate', `${data.kpis.publishSuccessRate.toLocaleString('de-DE')}%`); + dashboardSetText('dash-period-revenue', formatCurrency(data.timeline.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + dashboardSetText('dash-period-label', `Letzte ${data.periodDays} Tage`); + dashboardSetText('dash-platform-total', formatCurrency(data.platformRevenue.reduce((sum, item) => sum + Number(item.revenue || 0), 0))); + const timelineValues = data.timeline.map(item => item.revenue); + renderSparkline('spark-revenue', timelineValues, 'green'); + renderSparkline('spark-sales', data.timeline.map(item => item.sales), 'blue'); + renderSparkline('spark-active', timelineValues.map((_, index) => Math.max(0, data.kpis.activeListings - data.timeline.length + index)), 'purple'); + renderSparkline('spark-drafts', timelineValues.map((_, index) => Math.max(0, data.kpis.drafts + Math.sin(index) * 2)), 'orange'); + renderSparkline('spark-inventory', timelineValues.map((value, index) => data.kpis.inventoryValue * .9 + index * (data.kpis.inventoryValue * .1 / Math.max(1, timelineValues.length))), 'green'); + renderSparkline('spark-publish', timelineValues.map((_, index) => Math.max(0, data.kpis.publishSuccessRate - 4 + index / 3)), 'blue'); + renderDashboardLineChart(data.timeline); + renderDashboardBars(data.platformRevenue); + renderDashboardCategories(data.categoryRevenue); + renderDashboardFocus(data.focus); + renderDashboardTopSellers(data.topSellers); + renderDashboardFeed('dashboard-activity', data.activity); + renderDashboardFeed('dashboard-notifications', data.notifications, true); + dashboardSetText('dashboard-quality-value', `${data.kpis.quality}%`); + dashboardSetText('dashboard-quality-label', data.kpis.quality >= 85 ? 'Sehr hoch' : data.kpis.quality >= 65 ? 'Gut, mit Potenzial' : 'Optimierung empfohlen'); + document.getElementById('dashboard-quality-ring')?.style.setProperty('--confidence', `${data.kpis.quality}%`); + dashboardSetText('dashboard-system-state', data.system.healthy ? 'Alle Systeme sind betriebsbereit' : 'Prüfung erforderlich'); + dashboardSetText('dashboard-system-copy', `${data.system.queuePending} Queue-Einträge ausstehend · ${data.system.scheduled} Veröffentlichungen geplant.`); + } catch (error) { + toast(`Dashboard konnte nicht geladen werden: ${error.message}`); + } finally { refresh?.classList.remove('is-loading'); } +} + +function getStudioItem(id) { + if (!studioWorkflowData?.stages) return null; + for (const stage of Object.values(studioWorkflowData.stages)) { + const found = stage.items?.find(item => item.id === id); + if (found) return found; + } + return null; +} + +function currentTodayItems(data) { + if (!data?.stages) return []; + const key = todayFilter === 'all' ? 'ready' : todayFilter; + if (key === 'attention') return [...(data.stages.captured?.items || []), ...(data.stages.ai_review?.items || [])]; + return data.stages[key]?.items || []; +} + +function renderTodaySummary(data) { + const set = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = String(value || 0); }; + set('today-count-ready', data.stages?.ready?.count); + set('today-count-scheduled', data.stages?.scheduled?.count); + set('today-count-published', data.stages?.published?.count); + set('today-count-sold', data.stages?.sold?.count); + set('today-count-attention', data.attentionCount); + set('today-ready-total', data.stages?.ready?.count); + set('today-scheduled-total', data.stages?.scheduled?.count); +} + +function renderTodayReady(data) { + const container = document.getElementById('today-ready-grid'); + if (!container || !data) return; + const labels = { all: 'Bereit für den nächsten Schritt', ready: 'Bereit für den nächsten Schritt', scheduled: 'Geplante Artikel', published: 'Veröffentlichte Artikel', sold: 'Verkaufte Artikel', attention: 'Artikel mit Prüfbedarf' }; + const heading = container.closest('.work-panel')?.querySelector('h3'); + if (heading) heading.textContent = labels[todayFilter] || labels.all; + let items = [...currentTodayItems(data)]; + const sort = document.getElementById('today-sort')?.value || 'priority'; + if (sort === 'newest') items.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt))); + if (sort === 'price') items.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0)); + items = items.slice(0, 4); + if (!items.length) { container.innerHTML = '
                                                                      Für diesen Filter sind keine Artikel vorhanden.
                                                                      '; return; } + container.innerHTML = items.map(item => ``).join(''); + container.querySelectorAll('.today-product-media img').forEach(image => image.addEventListener('error', () => { + image.hidden = true; + const placeholder = document.createElement('span'); + placeholder.className = 'today-image-placeholder'; + placeholder.textContent = 'Bild nicht verfügbar'; + image.parentElement?.prepend(placeholder); + }, { once: true })); + container.querySelectorAll('[data-studio-listing]').forEach(card => card.addEventListener('click', () => selectStudioListing(Number(card.dataset.studioListing)))); +} + +function renderTodayScheduled(data) { + const body = document.getElementById('today-scheduled-list'); + if (!body) return; + const items = data.stages?.scheduled?.items?.slice(0, 4) || []; + body.innerHTML = items.length ? items.map(item => `${escapeHtml(platformLabel(item.platform))}${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}Geplant`).join('') : 'Keine geplanten Veröffentlichungen.'; + body.querySelectorAll('[data-scheduled-id]').forEach(button => button.addEventListener('click', () => selectStudioListing(Number(button.dataset.scheduledId)))); +} + +function renderStudioAttention(data) { + const list = document.getElementById('studio-attention'); + const count = document.getElementById('attention-count'); + if (count) count.textContent = String(data.attentionCount || 0); + if (!list) return; + if (!data.attention?.length) { list.innerHTML = '
                                                                      Alles erledigt – aktuell ist keine Aktion nötig.
                                                                      '; return; } + const grouped = new Map(); + for (const item of data.attention) { + const current = grouped.get(item.type) || { ...item, count: 0 }; + current.count += 1; + grouped.set(item.type, current); + } + list.innerHTML = [...grouped.values()].slice(0, 5).map(item => ``).join(''); + list.querySelectorAll('[data-attention-id]').forEach(button => button.addEventListener('click', () => { + const id = Number(button.dataset.attentionId); + if (button.dataset.attentionType === 'publish_failed') switchToTab('publish'); + else showDetail(id); + })); +} + +function renderStudioActivity(data) { + const recent = document.getElementById('studio-activity'); + const completed = document.getElementById('studio-completed'); + const render = (container, items) => { + if (!container) return; + container.innerHTML = items.length ? items.map(item => ``).join('') : '
                                                                      Noch keine Aktivität
                                                                      '; + container.querySelectorAll('[data-activity-listing]').forEach(button => button.addEventListener('click', () => { const id = Number(button.dataset.activityListing); if (id) selectStudioListing(id); })); + }; + const completeTypes = new Set(['published', 'sold']); + render(recent, data.activity.filter(item => !completeTypes.has(item.type)).slice(0, 4)); + render(completed, data.activity.filter(item => completeTypes.has(item.type)).slice(0, 4)); +} + +function selectStudioListing(id) { + const item = getStudioItem(id); + if (!item) return; + selectedStudioListingId = id; + document.querySelectorAll('[data-studio-listing]').forEach(card => card.classList.toggle('selected', Number(card.dataset.studioListing) === id)); + renderStudioInspector(item); +} + +function renderStudioInspector(item) { + const empty = document.getElementById('studio-inspector-empty'); + const content = document.getElementById('studio-inspector-content'); + if (!content || !empty) return; + empty.classList.add('hidden'); + content.classList.remove('hidden'); + const photos = item.photos || []; + const hero = photos[0] ? `${escapeHtml(item.title)}` : '
                                                                      Kein Bild
                                                                      '; + const confidence = Math.max(0, Math.min(100, item.completeness || 0)); + const low = Math.max(0, (Number(item.price) || 0) * .92); + const high = Math.max(low, (Number(item.price) || 0) * 1.08); + content.innerHTML = `

                                                                      ${escapeHtml(item.title)}

                                                                      SKU  ${escapeHtml(item.sku || '')}
                                                                      ${item.status === 'sold' ? 'Verkauft' : 'Bereit'}
                                                                      ${hero}▧ ${photos.length}
                                                                      ${photos.length > 1 ? `
                                                                      ${photos.slice(0, 5).map(photo => ``).join('')}${photos.length > 5 ? `+${photos.length - 5}` : ''}
                                                                      ` : ''}
                                                                      Preis${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}

                                                                      AI-Konfidenz

                                                                      ${confidence}%
                                                                      ${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}

                                                                      Wichtige Attribute

                                                                      Marke
                                                                      ${escapeHtml(item.brand || '—')}
                                                                      Kategorie
                                                                      ${escapeHtml(item.category || '—')}
                                                                      Farbe
                                                                      ${escapeHtml(item.color || '—')}
                                                                      Größe
                                                                      ${escapeHtml(item.size || '—')}
                                                                      Zustand
                                                                      ${escapeHtml(item.condition || '—')}
                                                                      Lagerort
                                                                      ${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}
                                                                      ${item.price ? `

                                                                      Preisempfehlung

                                                                      ${escapeHtml(formatCurrency(low))} – ${escapeHtml(formatCurrency(high))}
                                                                      ` : ''}
                                                                      `; + content.querySelector('[data-inspector-action="review"]')?.addEventListener('click', () => showDetail(item.id)); + content.querySelector('[data-inspector-action="publish"]')?.addEventListener('click', () => { publishState.selectedId = item.id; switchToTab('publish'); }); + content.querySelector('[data-inspector-action="inventory"]')?.addEventListener('click', () => switchToTab('inventory')); +} + +async function loadToday() { + const ready = document.getElementById('today-ready-grid'); + if (ready) ready.innerHTML = '
                                                                      Studio Flow wird geladen…
                                                                      '; + try { + const data = await api('GET', '/api/dashboard/workflow?limit=12'); + studioWorkflowData = data; + renderTodaySummary(data); + renderTodayReady(data); + renderTodayScheduled(data); + renderStudioAttention(data); + renderStudioActivity(data); + const first = data.stages?.ready?.items?.[0] || data.stages?.scheduled?.items?.[0] || data.stages?.published?.items?.[0] || data.stages?.sold?.items?.[0]; + if (first && !selectedStudioListingId) selectStudioListing(first.id); + else if (selectedStudioListingId) { const selected = getStudioItem(selectedStudioListingId); if (selected) renderStudioInspector(selected); } + } catch (error) { + if (ready) ready.innerHTML = `
                                                                      Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + + +// --- Advanced Image Editor --- +function createPhotoEditorOperations() { + return { + rotation: 0, + straighten: 0, + flip_h: false, + flip_v: false, + crop: null, + exposure: 0, + brightness: 0, + contrast: 0, + highlights: 0, + shadows: 0, + temperature: 0, + saturation: 0, + sharpness: 0, + remove_background: false, + background_sensitivity: 45, + watermark: { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 }, + resize: { width: 0, height: 0 }, + format: 'jpeg', + quality: 90, + }; +} + +let photoEditorState = { + context: 'generator', platform: '', index: -1, + currentPath: '', rootPath: '', sourceImage: null, rootImage: null, + operations: createPhotoEditorOperations(), history: [], historyIndex: -1, + versions: [], compare: false, cropping: false, cropRect: null, + cropStart: null, cropEnd: null, _dragOrigin: null, _dragging: false, +}; + +function cloneEditorOperations(value = photoEditorState.operations) { + return JSON.parse(JSON.stringify(value)); +} + +function getPhotoEditorPhotos() { + if (photoEditorState.context === 'flux') return state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []; + if (photoEditorState.context === 'media') return state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []; + return photoEditorState.context === 'detail' ? (detailEditorState?.photos || []) : state.photos; +} + +async function commitPhotoEditorResult(filename) { + if (!filename) return; + if (photoEditorState.context === 'flux') { + const current = state.fluxStudioCurrent || {}; + const item = await api('POST', '/api/flux-studio/images', { + image_path: filename, + prompt: current.prompt || 'Nachbearbeitete FLUX-Ausgabe', + width: current.width, + height: current.height, + seed: current.seed, + steps: current.steps, + style: current.style, + final_prompt: current.final_prompt, + profile: current.profile, + duration_ms: current.duration_ms, + edited_from: current.id || null, + favorite: current.favorite, + job_id: current.job_id, + variant_index: current.variant_index, + prompt_mode: current.prompt_mode, + parameters: current.parameters, + }); + renderFluxStudioPreview(item); + await loadFluxStudioHistory(); + return; + } + if (photoEditorState.context === 'media') { + state.mediaCurrent = { ...(state.mediaCurrent || {}), image_path: filename, public_url: photoUrl(filename), source: 'edited', sources: ['edited'] }; + await loadMediaLibrary(); + return; + } + const photos = getPhotoEditorPhotos(); + if (!photos[photoEditorState.index]) return; + photos[photoEditorState.index] = filename; + if (photoEditorState.context === 'detail') renderDetailPhotos(); + else renderPhotoPreview(); +} + +function getPhotoEditorPlatform() { + if (photoEditorState.context === 'flux') return 'ai-generated'; + if (photoEditorState.context === 'media') return state.mediaCurrent?.source || 'media-library'; + if (photoEditorState.context === 'detail') { + return document.getElementById('detail-platform')?.value || detailEditorState?.listing?.platform || 'allgemein'; + } + return state.platform || 'allgemein'; +} + +function setupPhotoEditor() { + document.getElementById('photo-editor-close').addEventListener('click', closePhotoEditor); + document.getElementById('pe-cancel').addEventListener('click', closePhotoEditor); + document.getElementById('photo-editor-overlay').addEventListener('click', event => { + if (event.target === event.currentTarget) closePhotoEditor(); + }); + + document.getElementById('pe-undo').addEventListener('click', () => stepPhotoEditorHistory(-1)); + document.getElementById('pe-redo').addEventListener('click', () => stepPhotoEditorHistory(1)); + document.getElementById('pe-compare').addEventListener('click', togglePhotoEditorCompare); + document.getElementById('pe-restore-original').addEventListener('click', restorePhotoEditorOriginal); + + const sliders = { + straighten: { digits: 1, suffix: '°' }, + exposure: { digits: 1, suffix: '' }, + brightness: { digits: 0, suffix: '' }, + contrast: { digits: 0, suffix: '' }, + highlights: { digits: 0, suffix: '' }, + shadows: { digits: 0, suffix: '' }, + temperature: { digits: 0, suffix: '' }, + saturation: { digits: 0, suffix: '' }, + sharpness: { digits: 0, suffix: '' }, + }; + for (const [property, format] of Object.entries(sliders)) { + const slider = document.getElementById(`pe-${property}`); + slider.addEventListener('input', () => { + photoEditorState.operations[property] = Number(slider.value); + updatePhotoEditorRangeValue(property, format.digits, format.suffix); + renderPhotoEditorCanvas(); + }); + slider.addEventListener('change', pushPhotoEditorHistory); + } + + document.getElementById('pe-rotate-left').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 270) % 360; + })); + document.getElementById('pe-rotate-right').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 90) % 360; + })); + document.getElementById('pe-flip-h').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.flip_h = !photoEditorState.operations.flip_h; + })); + document.getElementById('pe-flip-v').addEventListener('click', () => mutatePhotoEditor(() => { + photoEditorState.operations.flip_v = !photoEditorState.operations.flip_v; + })); + + document.getElementById('pe-reset-adjustments').addEventListener('click', () => mutatePhotoEditor(() => { + for (const property of ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) { + photoEditorState.operations[property] = 0; + } + })); + + document.getElementById('pe-crop-toggle').addEventListener('click', toggleCropMode); + document.getElementById('pe-crop-apply').addEventListener('click', applyCropSelection); + document.getElementById('pe-crop-cancel').addEventListener('click', cancelCropMode); + document.getElementById('pe-crop-ratio').addEventListener('change', setDefaultCropSelection); + + document.getElementById('pe-remove-background').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.remove_background = event.target.checked; + if (event.target.checked && photoEditorState.operations.format === 'jpeg') photoEditorState.operations.format = 'png'; + })); + document.getElementById('pe-background-sensitivity').addEventListener('input', event => { + photoEditorState.operations.background_sensitivity = Number(event.target.value); + document.getElementById('pe-background-sensitivity-val').textContent = `${event.target.value}%`; + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-background-sensitivity').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-watermark-enabled').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.watermark.enabled = event.target.checked; + })); + document.getElementById('pe-watermark-text').addEventListener('input', event => { + photoEditorState.operations.watermark.text = event.target.value.slice(0, 80); + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-watermark-text').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-watermark-position').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.watermark.position = event.target.value; + })); + document.getElementById('pe-watermark-opacity').addEventListener('input', event => { + photoEditorState.operations.watermark.opacity = Number(event.target.value); + document.getElementById('pe-watermark-opacity-val').textContent = `${event.target.value}%`; + renderPhotoEditorCanvas(); + }); + document.getElementById('pe-watermark-opacity').addEventListener('change', pushPhotoEditorHistory); + + const cropOverlay = document.getElementById('photo-editor-crop-overlay'); + cropOverlay.addEventListener('mousedown', cropMouseDown); + cropOverlay.addEventListener('mousemove', cropMouseMove); + cropOverlay.addEventListener('mouseup', cropMouseUp); + cropOverlay.addEventListener('mouseleave', cropMouseUp); + cropOverlay.addEventListener('touchstart', event => { event.preventDefault(); cropMouseDown(touchToMouse(event)); }, { passive: false }); + cropOverlay.addEventListener('touchmove', event => { event.preventDefault(); cropMouseMove(touchToMouse(event)); }, { passive: false }); + cropOverlay.addEventListener('touchend', cropMouseUp); + + document.getElementById('pe-format').addEventListener('change', event => mutatePhotoEditor(() => { + photoEditorState.operations.format = event.target.value; + }, false)); + document.getElementById('pe-quality').addEventListener('input', event => { + photoEditorState.operations.quality = Number(event.target.value); + document.getElementById('pe-quality-val').textContent = `${event.target.value}%`; + }); + document.getElementById('pe-quality').addEventListener('change', pushPhotoEditorHistory); + document.getElementById('pe-target-size').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-target-width').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-target-height').addEventListener('change', updatePhotoEditorResize); + document.getElementById('pe-apply').addEventListener('click', applyPhotoEdit); + + window.addEventListener('resize', () => { + if (!document.getElementById('photo-editor-overlay').classList.contains('hidden')) syncCropOverlay(); + }); + document.addEventListener('keydown', event => { + const overlay = document.getElementById('photo-editor-overlay'); + if (!overlay || overlay.classList.contains('hidden')) return; + if (event.key === 'Escape') { + event.preventDefault(); + closePhotoEditor(); + return; + } + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return; + event.preventDefault(); + stepPhotoEditorHistory(event.shiftKey ? 1 : -1); + }); +} + +function updatePhotoEditorRangeValue(property, digits = 0, suffix = '') { + const value = Number(photoEditorState.operations[property] || 0); + const text = digits ? value.toFixed(digits).replace('.', ',') : String(Math.round(value)); + document.getElementById(`pe-${property}-val`).textContent = `${text}${suffix}`; +} + +function updatePhotoEditorControls() { + for (const property of ['straighten', 'exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) { + const input = document.getElementById(`pe-${property}`); + input.value = photoEditorState.operations[property]; + } + updatePhotoEditorRangeValue('straighten', 1, '°'); + updatePhotoEditorRangeValue('exposure', 1); + for (const property of ['brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) updatePhotoEditorRangeValue(property); + document.getElementById('pe-format').value = photoEditorState.operations.format; + document.getElementById('pe-quality').value = photoEditorState.operations.quality; + document.getElementById('pe-quality-val').textContent = `${photoEditorState.operations.quality}%`; + document.getElementById('pe-remove-background').checked = Boolean(photoEditorState.operations.remove_background); + document.getElementById('pe-background-sensitivity').value = Number(photoEditorState.operations.background_sensitivity ?? 45); + document.getElementById('pe-background-sensitivity-val').textContent = `${Number(photoEditorState.operations.background_sensitivity ?? 45)}%`; + const watermark = photoEditorState.operations.watermark || { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 }; + document.getElementById('pe-watermark-enabled').checked = Boolean(watermark.enabled); + document.getElementById('pe-watermark-text').value = watermark.text || 'Vendoo'; + document.getElementById('pe-watermark-position').value = watermark.position || 'bottom-right'; + document.getElementById('pe-watermark-opacity').value = Number(watermark.opacity || 40); + document.getElementById('pe-watermark-opacity-val').textContent = `${Number(watermark.opacity || 40)}%`; + + const { width, height } = photoEditorState.operations.resize || {}; + const target = width && height && width === height && [1200, 1600, 2048].includes(width) ? String(width) : width || height ? 'custom' : 'original'; + document.getElementById('pe-target-size').value = target; + document.getElementById('pe-custom-size').classList.toggle('hidden', target !== 'custom'); + document.getElementById('pe-target-width').value = target === 'custom' && width ? width : ''; + document.getElementById('pe-target-height').value = target === 'custom' && height ? height : ''; +} + +function mutatePhotoEditor(mutator, render = true) { + mutator(); + updatePhotoEditorControls(); + pushPhotoEditorHistory(); + if (render) renderPhotoEditorCanvas(); +} + +function pushPhotoEditorHistory() { + const snapshot = cloneEditorOperations(); + const current = photoEditorState.history[photoEditorState.historyIndex]; + if (current && JSON.stringify(current) === JSON.stringify(snapshot)) return; + photoEditorState.history = photoEditorState.history.slice(0, photoEditorState.historyIndex + 1); + photoEditorState.history.push(snapshot); + photoEditorState.historyIndex = photoEditorState.history.length - 1; + updatePhotoEditorHistoryControls(); +} + +function stepPhotoEditorHistory(direction) { + const next = photoEditorState.historyIndex + direction; + if (next < 0 || next >= photoEditorState.history.length) return; + photoEditorState.historyIndex = next; + photoEditorState.operations = cloneEditorOperations(photoEditorState.history[next]); + cancelCropMode(false); + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); + renderPhotoEditorCanvas(); +} + +function updatePhotoEditorHistoryControls() { + document.getElementById('pe-undo').disabled = photoEditorState.historyIndex <= 0; + document.getElementById('pe-redo').disabled = photoEditorState.historyIndex >= photoEditorState.history.length - 1; + document.getElementById('pe-history-status').textContent = `Schritt ${Math.max(1, photoEditorState.historyIndex + 1)} von ${Math.max(1, photoEditorState.history.length)}`; +} + +function updatePhotoEditorResize() { + const target = document.getElementById('pe-target-size').value; + const custom = document.getElementById('pe-custom-size'); + custom.classList.toggle('hidden', target !== 'custom'); + if (target === 'original') photoEditorState.operations.resize = { width: 0, height: 0 }; + else if (target === 'custom') { + photoEditorState.operations.resize = { + width: Math.max(0, Number(document.getElementById('pe-target-width').value) || 0), + height: Math.max(0, Number(document.getElementById('pe-target-height').value) || 0), + }; + } else { + const size = Number(target); + photoEditorState.operations.resize = { width: size, height: size }; + } + pushPhotoEditorHistory(); + renderPhotoEditorCanvas(); +} + +function loadEditorImage(path) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Bildvorschau konnte nicht geladen werden.')); + image.src = `${photoUrl(path)}${String(photoUrl(path)).includes('?') ? '&' : '?'}editor=${Date.now()}`; + }); +} + +async function openPhotoEditor(index, context = 'generator') { + const photos = context === 'flux' ? (state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []) : context === 'media' ? (state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []) : context === 'detail' ? (detailEditorState?.photos || []) : state.photos; + const photo = photos[index]; + if (!photo) return; + + photoEditorState = { + context, + platform: context === 'flux' ? 'ai-generated' : context === 'media' ? (state.mediaCurrent?.source || 'media-library') : context === 'detail' ? (detailEditorState?.listing?.platform || '') : state.platform, + index, + currentPath: photo, + rootPath: photo, + sourceImage: null, + rootImage: null, + operations: createPhotoEditorOperations(), + history: [], historyIndex: -1, versions: [], compare: false, + cropping: false, cropRect: null, cropStart: null, cropEnd: null, + _dragOrigin: null, _dragging: false, + }; + + const overlay = document.getElementById('photo-editor-overlay'); + overlay.classList.remove('hidden'); + overlay.setAttribute('aria-hidden', 'false'); + document.getElementById('pe-image-meta').textContent = 'Versionen werden geladen …'; + document.getElementById('pe-version-list').innerHTML = '
                                                                      Versionen werden geladen …
                                                                      '; + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); + setPhotoEditorCompare(false); + + try { + const history = await api('GET', `/api/image-editor/versions?path=${encodeURIComponent(photo)}`); + photoEditorState.rootPath = history.root_path || photo; + photoEditorState.versions = [history.original, ...(history.versions || [])].filter(Boolean); + const [sourceImage, rootImage] = await Promise.all([ + loadEditorImage(photo), + history.root_path && history.root_path !== photo ? loadEditorImage(history.root_path) : loadEditorImage(photo), + ]); + photoEditorState.sourceImage = sourceImage; + photoEditorState.rootImage = rootImage; + resetPhotoEditorSession(); + renderPhotoEditorVersions(); + renderPhotoEditorCanvas(); + } catch (error) { + toast(error.message || 'Bildeditor konnte nicht geöffnet werden.'); + closePhotoEditor(); + } +} + +function resetPhotoEditorSession() { + const extension = String(photoEditorState.currentPath || '').split('.').pop()?.toLowerCase(); + photoEditorState.operations = createPhotoEditorOperations(); + if (extension === 'png') photoEditorState.operations.format = 'png'; + else if (extension === 'webp') photoEditorState.operations.format = 'webp'; + photoEditorState.history = [cloneEditorOperations()]; + photoEditorState.historyIndex = 0; + cancelCropMode(false); + updatePhotoEditorControls(); + updatePhotoEditorHistoryControls(); +} + +async function loadPhotoEditorVersion(path) { + if (!path || path === photoEditorState.currentPath) return; + try { + document.getElementById('pe-image-meta').textContent = 'Version wird geladen …'; + photoEditorState.sourceImage = await loadEditorImage(path); + photoEditorState.currentPath = path; + resetPhotoEditorSession(); + renderPhotoEditorVersions(); + renderPhotoEditorCanvas(); + } catch (error) { toast(error.message); } +} + +function formatEditorFileSize(bytes) { + const size = Number(bytes) || 0; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`; + return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`; +} + +function renderPhotoEditorVersions() { + const list = document.getElementById('pe-version-list'); + const versions = photoEditorState.versions || []; + document.getElementById('pe-version-count').textContent = String(Math.max(0, versions.length - 1)); + if (!versions.length) { + list.innerHTML = '
                                                                      Noch keine Bearbeitungsversion.
                                                                      '; + return; + } + list.innerHTML = versions.map((version, index) => { + const path = version.output_path || version.root_path; + const isOriginal = version.id === 'original'; + const date = version.created_at ? new Date(version.created_at).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; + const dimensions = version.width && version.height ? `${version.width} × ${version.height}` : ''; + return ``; + }).join(''); + list.querySelectorAll('[data-version-path]').forEach(button => button.addEventListener('click', () => loadPhotoEditorVersion(button.dataset.versionPath))); +} + +function createPreviewCanvas(image, operations, { applyCrop = true, maxDimension = 1100 } = {}) { + const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height)); + const sourceWidth = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); + const sourceHeight = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); + const angle = (operations.rotation + operations.straighten) * Math.PI / 180; + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + const outputWidth = Math.max(1, Math.ceil(sourceWidth * cos + sourceHeight * sin)); + const outputHeight = Math.max(1, Math.ceil(sourceWidth * sin + sourceHeight * cos)); + const geometry = document.createElement('canvas'); + geometry.width = outputWidth; + geometry.height = outputHeight; + const geometryContext = geometry.getContext('2d', { willReadFrequently: true }); + geometryContext.fillStyle = '#ffffff'; + geometryContext.fillRect(0, 0, outputWidth, outputHeight); + geometryContext.save(); + geometryContext.translate(outputWidth / 2, outputHeight / 2); + geometryContext.rotate(angle); + geometryContext.scale(operations.flip_h ? -1 : 1, operations.flip_v ? -1 : 1); + geometryContext.drawImage(image, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight); + geometryContext.restore(); + + let result = geometry; + if (applyCrop && operations.crop) { + const left = Math.max(0, Math.min(geometry.width - 1, Math.round(operations.crop.x * geometry.width))); + const top = Math.max(0, Math.min(geometry.height - 1, Math.round(operations.crop.y * geometry.height))); + const width = Math.max(1, Math.min(geometry.width - left, Math.round(operations.crop.width * geometry.width))); + const height = Math.max(1, Math.min(geometry.height - top, Math.round(operations.crop.height * geometry.height))); + result = document.createElement('canvas'); + result.width = width; + result.height = height; + result.getContext('2d', { willReadFrequently: true }).drawImage(geometry, left, top, width, height, 0, 0, width, height); + } + + applyPreviewToneAdjustments(result, operations); + applyPreviewBackgroundRemoval(result, operations); + applyPreviewWatermark(result, operations); + return result; +} + +function applyPreviewBackgroundRemoval(canvas, operations) { + if (!operations.remove_background) return; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const cornerIndexes = [0, (canvas.width - 1) * 4, ((canvas.height - 1) * canvas.width) * 4, (canvas.width * canvas.height - 1) * 4]; + const background = cornerIndexes.reduce((sum, index) => ({ r: sum.r + data[index], g: sum.g + data[index + 1], b: sum.b + data[index + 2] }), { r: 0, g: 0, b: 0 }); + background.r /= 4; background.g /= 4; background.b /= 4; + const level = Math.max(0, Math.min(100, Number(operations.background_sensitivity ?? 45))); + const solidThreshold = 24 + level * 2.1; + const featherWidth = 45 + level * 1.2; + const featherEnd = solidThreshold + featherWidth; + for (let index = 0; index < data.length; index += 4) { + const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); + if (distance <= solidThreshold) data[index + 3] = 0; + else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth); + } + context.putImageData(imageData, 0, 0); +} + +function applyPreviewWatermark(canvas, operations) { + const watermark = operations.watermark || {}; + const text = String(watermark.text || '').trim(); + if (!watermark.enabled || !text) return; + const context = canvas.getContext('2d'); + const margin = Math.max(10, Math.round(Math.min(canvas.width, canvas.height) * 0.025)); + const fontSize = Math.max(16, Math.round(canvas.width * 0.04)); + const position = watermark.position || 'bottom-right'; + let x = canvas.width - margin; let y = canvas.height - margin; let align = 'right'; let baseline = 'bottom'; + if (position.includes('left')) { x = margin; align = 'left'; } + if (position.includes('top')) { y = margin; baseline = 'top'; } + if (position === 'center') { x = canvas.width / 2; y = canvas.height / 2; align = 'center'; baseline = 'middle'; } + context.save(); + context.globalAlpha = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100)); + context.font = `700 ${fontSize}px system-ui, sans-serif`; + context.textAlign = align; + context.textBaseline = baseline; + context.lineWidth = Math.max(2, fontSize * 0.08); + context.strokeStyle = 'rgba(0,0,0,.42)'; + context.fillStyle = '#ffffff'; + context.strokeText(text, x, y); + context.fillText(text, x, y); + context.restore(); +} + +function applyPreviewToneAdjustments(canvas, operations) { + const context = canvas.getContext('2d', { willReadFrequently: true }); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const exposure = 2 ** operations.exposure; + const brightness = operations.brightness / 100 * 0.22; + const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100)); + const contrast = (1 + contrastValue) / (1 - contrastValue); + const highlights = operations.highlights / 100; + const shadows = operations.shadows / 100; + const temperature = operations.temperature / 100; + const saturation = 1 + operations.saturation / 100; + + for (let index = 0; index < data.length; index += 4) { + let red = data[index] / 255; + let green = data[index + 1] / 255; + let blue = data[index + 2] / 255; + red = (red * exposure + brightness - 0.5) * contrast + 0.5; + green = (green * exposure + brightness - 0.5) * contrast + 0.5; + blue = (blue * exposure + brightness - 0.5) * contrast + 0.5; + const lumBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722)); + const shadowWeight = (1 - lumBefore) ** 2; + const highlightWeight = lumBefore ** 2; + const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28; + const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38; + red += shadowDelta + highlightDelta + temperature * 0.12; + green += shadowDelta + highlightDelta + temperature * 0.015; + blue += shadowDelta + highlightDelta - temperature * 0.12; + const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; + red = luminance + (red - luminance) * saturation; + green = luminance + (green - luminance) * saturation; + blue = luminance + (blue - luminance) * saturation; + data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255); + data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255); + data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255); + } + context.putImageData(imageData, 0, 0); + if (operations.sharpness > 0) applyPreviewSharpen(canvas, operations.sharpness / 100); +} + +function applyPreviewSharpen(canvas, strength) { + if (canvas.width * canvas.height > 1_500_000) return; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const source = context.getImageData(0, 0, canvas.width, canvas.height); + const output = context.createImageData(canvas.width, canvas.height); + output.data.set(source.data); + const amount = Math.min(1, Math.max(0, strength)) * 0.75; + const width = canvas.width; + for (let y = 1; y < canvas.height - 1; y += 1) { + for (let x = 1; x < width - 1; x += 1) { + const index = (y * width + x) * 4; + for (let channel = 0; channel < 3; channel += 1) { + const center = source.data[index + channel]; + const average = (source.data[index - 4 + channel] + source.data[index + 4 + channel] + source.data[index - width * 4 + channel] + source.data[index + width * 4 + channel]) / 4; + output.data[index + channel] = Math.max(0, Math.min(255, center + (center - average) * amount)); + } + } + } + context.putImageData(output, 0, 0); +} + +function calculatePhotoEditorOutputDimensions() { + const image = photoEditorState.sourceImage; + if (!image) return { width: 0, height: 0 }; + const angle = (photoEditorState.operations.rotation + photoEditorState.operations.straighten) * Math.PI / 180; + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + let width = Math.ceil(image.naturalWidth * cos + image.naturalHeight * sin); + let height = Math.ceil(image.naturalWidth * sin + image.naturalHeight * cos); + if (photoEditorState.operations.crop) { + width = Math.max(1, Math.round(width * photoEditorState.operations.crop.width)); + height = Math.max(1, Math.round(height * photoEditorState.operations.crop.height)); + } + const target = photoEditorState.operations.resize || {}; + if (target.width || target.height) { + const scale = Math.min(target.width ? target.width / width : Infinity, target.height ? target.height / height : Infinity, 1); + width = Math.max(1, Math.round(width * scale)); + height = Math.max(1, Math.round(height * scale)); + } + return { width, height }; +} + +function renderPhotoEditorCanvas() { + const image = photoEditorState.sourceImage; + if (!image) return; + const preview = createPreviewCanvas(image, photoEditorState.operations, { applyCrop: !photoEditorState.cropping }); + const canvas = document.getElementById('photo-editor-canvas'); + canvas.width = preview.width; + canvas.height = preview.height; + canvas.getContext('2d').drawImage(preview, 0, 0); + + if (photoEditorState.rootImage) { + const before = createPreviewCanvas(photoEditorState.rootImage, createPhotoEditorOperations(), { maxDimension: 850 }); + const beforeCanvas = document.getElementById('photo-editor-before-canvas'); + beforeCanvas.width = before.width; + beforeCanvas.height = before.height; + beforeCanvas.getContext('2d').drawImage(before, 0, 0); + } + + const dimensions = calculatePhotoEditorOutputDimensions(); + document.getElementById('pe-output-dimensions').textContent = dimensions.width && dimensions.height ? `Ausgabe: ${dimensions.width} × ${dimensions.height} px` : 'Ausgabegröße wird ermittelt'; + document.getElementById('pe-image-meta').textContent = `${photoEditorState.currentPath === photoEditorState.rootPath ? 'Original' : 'Version'} · ${image.naturalWidth} × ${image.naturalHeight} px · Original bleibt erhalten`; + requestAnimationFrame(syncCropOverlay); +} + +function setPhotoEditorCompare(enabled) { + photoEditorState.compare = Boolean(enabled); + document.getElementById('pe-before-pane').classList.toggle('hidden', !photoEditorState.compare); + const button = document.getElementById('pe-compare'); + button.setAttribute('aria-pressed', String(photoEditorState.compare)); + button.classList.toggle('active', photoEditorState.compare); +} + +function togglePhotoEditorCompare() { + setPhotoEditorCompare(!photoEditorState.compare); + requestAnimationFrame(syncCropOverlay); +} + +function touchToMouse(event) { + const touch = event.touches?.[0] || event.changedTouches?.[0]; + return { clientX: touch?.clientX || 0, clientY: touch?.clientY || 0, target: event.target }; +} + +function toggleCropMode() { + if (photoEditorState.cropping) return cancelCropMode(); + photoEditorState.cropping = true; + document.getElementById('pe-crop-toggle').classList.add('active'); + document.getElementById('pe-crop-toggle').textContent = 'Auswahl aktiv'; + document.getElementById('pe-crop-apply').classList.remove('hidden'); + document.getElementById('pe-crop-cancel').classList.remove('hidden'); + document.getElementById('pe-crop-hint').textContent = 'Rahmen aufziehen oder verschieben; anschließend anwenden.'; + renderPhotoEditorCanvas(); + requestAnimationFrame(() => { + syncCropOverlay(); + if (photoEditorState.operations.crop) { + const overlay = document.getElementById('photo-editor-crop-overlay'); + photoEditorState.cropRect = { + x: photoEditorState.operations.crop.x * overlay.width, + y: photoEditorState.operations.crop.y * overlay.height, + w: photoEditorState.operations.crop.width * overlay.width, + h: photoEditorState.operations.crop.height * overlay.height, + }; + drawCropOverlay(); + } else setDefaultCropSelection(); + }); +} + +function cancelCropMode(render = true) { + photoEditorState.cropping = false; + photoEditorState.cropRect = null; + photoEditorState.cropStart = null; + photoEditorState.cropEnd = null; + photoEditorState._dragOrigin = null; + photoEditorState._dragging = false; + const overlay = document.getElementById('photo-editor-crop-overlay'); + overlay.classList.add('hidden'); + clearCropOverlay(); + const toggle = document.getElementById('pe-crop-toggle'); + toggle.classList.remove('active'); + toggle.textContent = '✂ Auswahl starten'; + document.getElementById('pe-crop-apply').classList.add('hidden'); + document.getElementById('pe-crop-cancel').classList.add('hidden'); + document.getElementById('pe-crop-hint').textContent = photoEditorState.operations.crop ? 'Ein nicht destruktiver Zuschnitt ist aktiv.' : 'Zuschnitt ist nicht aktiv.'; + if (render) renderPhotoEditorCanvas(); +} + +function syncCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const canvas = document.getElementById('photo-editor-canvas'); + const wrap = document.getElementById('photo-editor-canvas-wrap'); + if (!photoEditorState.cropping || !canvas.offsetWidth || !canvas.offsetHeight) { + overlay.classList.add('hidden'); + return; + } + const canvasRect = canvas.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + overlay.width = Math.max(1, Math.round(canvasRect.width)); + overlay.height = Math.max(1, Math.round(canvasRect.height)); + overlay.style.width = `${canvasRect.width}px`; + overlay.style.height = `${canvasRect.height}px`; + overlay.style.left = `${canvasRect.left - wrapRect.left}px`; + overlay.style.top = `${canvasRect.top - wrapRect.top}px`; + overlay.classList.remove('hidden'); + drawCropOverlay(); +} + +function getCropRatio() { + const value = document.getElementById('pe-crop-ratio').value; + if (value === 'free') return null; + if (value === 'original') { + const canvas = document.getElementById('photo-editor-canvas'); + return canvas.width / Math.max(1, canvas.height); + } + const [width, height] = value.split(':').map(Number); + return width && height ? width / height : null; +} + +function setDefaultCropSelection() { + if (!photoEditorState.cropping) return; + const overlay = document.getElementById('photo-editor-crop-overlay'); + const ratio = getCropRatio(); + const margin = Math.min(overlay.width, overlay.height) * 0.08; + let width = Math.max(20, overlay.width - margin * 2); + let height = Math.max(20, overlay.height - margin * 2); + if (ratio) { + if (width / height > ratio) width = height * ratio; + else height = width / ratio; + } + photoEditorState.cropRect = { x: (overlay.width - width) / 2, y: (overlay.height - height) / 2, w: width, h: height }; + drawCropOverlay(); +} + +function getCropCanvasCoords(event) { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const rect = overlay.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(overlay.width, event.clientX - rect.left)), + y: Math.max(0, Math.min(overlay.height, event.clientY - rect.top)), + }; +} + +function cropMouseDown(event) { + if (!photoEditorState.cropping) return; + photoEditorState._dragOrigin = getCropCanvasCoords(event); + photoEditorState._dragging = true; + photoEditorState.cropStart = photoEditorState._dragOrigin; + photoEditorState.cropEnd = photoEditorState._dragOrigin; +} + +function cropMouseMove(event) { + if (!photoEditorState.cropping || !photoEditorState._dragging || !photoEditorState._dragOrigin) return; + const overlay = document.getElementById('photo-editor-crop-overlay'); + const origin = photoEditorState._dragOrigin; + let point = getCropCanvasCoords(event); + const ratio = getCropRatio(); + if (ratio) { + const directionX = point.x >= origin.x ? 1 : -1; + const directionY = point.y >= origin.y ? 1 : -1; + let width = Math.abs(point.x - origin.x); + let height = Math.abs(point.y - origin.y); + if (width / Math.max(1, height) > ratio) height = width / ratio; + else width = height * ratio; + width = Math.min(width, directionX > 0 ? overlay.width - origin.x : origin.x); + height = Math.min(height, directionY > 0 ? overlay.height - origin.y : origin.y); + if (width / Math.max(1, height) > ratio) width = height * ratio; + else height = width / ratio; + point = { x: origin.x + width * directionX, y: origin.y + height * directionY }; + } + photoEditorState.cropEnd = point; + const x = Math.min(origin.x, point.x); + const y = Math.min(origin.y, point.y); + photoEditorState.cropRect = { x, y, w: Math.abs(point.x - origin.x), h: Math.abs(point.y - origin.y) }; + drawCropOverlay(); +} + +function cropMouseUp() { + if (!photoEditorState.cropping) return; + photoEditorState._dragging = false; + photoEditorState._dragOrigin = null; +} + +function drawCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + const context = overlay.getContext('2d'); + context.clearRect(0, 0, overlay.width, overlay.height); + if (!photoEditorState.cropping) return; + context.fillStyle = 'rgba(7, 11, 17, 0.58)'; + context.fillRect(0, 0, overlay.width, overlay.height); + const rect = photoEditorState.cropRect; + if (!rect || rect.w < 2 || rect.h < 2) return; + context.clearRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = '#ffffff'; + context.lineWidth = 2; + context.setLineDash([]); + context.strokeRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = 'rgba(255,255,255,.42)'; + context.lineWidth = 1; + for (let index = 1; index < 3; index += 1) { + context.beginPath(); context.moveTo(rect.x + rect.w * index / 3, rect.y); context.lineTo(rect.x + rect.w * index / 3, rect.y + rect.h); context.stroke(); + context.beginPath(); context.moveTo(rect.x, rect.y + rect.h * index / 3); context.lineTo(rect.x + rect.w, rect.y + rect.h * index / 3); context.stroke(); + } + const canvas = document.getElementById('photo-editor-canvas'); + const realWidth = Math.round(rect.w * canvas.width / overlay.width); + const realHeight = Math.round(rect.h * canvas.height / overlay.height); + const label = `${realWidth} × ${realHeight}`; + context.font = '12px ui-monospace, monospace'; + const textWidth = context.measureText(label).width; + context.fillStyle = 'rgba(0,0,0,.74)'; + context.fillRect(rect.x + rect.w / 2 - textWidth / 2 - 7, Math.max(rect.y + 5, rect.y + rect.h - 25), textWidth + 14, 20); + context.fillStyle = '#ffffff'; + context.textAlign = 'center'; + context.fillText(label, rect.x + rect.w / 2, Math.max(rect.y + 19, rect.y + rect.h - 11)); +} + +function clearCropOverlay() { + const overlay = document.getElementById('photo-editor-crop-overlay'); + overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height); +} + +function applyCropSelection() { + const rect = photoEditorState.cropRect; + const overlay = document.getElementById('photo-editor-crop-overlay'); + if (!rect || rect.w < 10 || rect.h < 10) return toast('Bitte zuerst einen gültigen Zuschnitt wählen.'); + photoEditorState.operations.crop = { + x: Math.max(0, Math.min(1, rect.x / overlay.width)), + y: Math.max(0, Math.min(1, rect.y / overlay.height)), + width: Math.max(0.01, Math.min(1, rect.w / overlay.width)), + height: Math.max(0.01, Math.min(1, rect.h / overlay.height)), + }; + cancelCropMode(false); + pushPhotoEditorHistory(); + renderPhotoEditorCanvas(); +} + +async function restorePhotoEditorOriginal() { + if (!photoEditorState.rootPath) return; + try { + await commitPhotoEditorResult(photoEditorState.rootPath); + toast('Unverändertes Original wiederhergestellt.'); + closePhotoEditor(); + } catch (error) { toast(error.message || 'Original konnte nicht wiederhergestellt werden.'); } +} + +async function applyPhotoEdit() { + const button = document.getElementById('pe-apply'); + const originalLabel = button.textContent; + button.disabled = true; + button.textContent = 'Neue Version wird gerendert …'; + try { + const result = await api('POST', '/api/image-editor/render', { + source_path: photoEditorState.currentPath, + context: photoEditorState.context, + platform: getPhotoEditorPlatform(), + operations: photoEditorState.operations, + }); + await commitPhotoEditorResult(result.filename); + toast(`Neue Bildversion gespeichert (${result.metadata?.width || '?'} × ${result.metadata?.height || '?'} px).`); + closePhotoEditor(); + } catch (error) { + toast(error.message || 'Bildversion konnte nicht gespeichert werden.'); + } finally { + button.disabled = false; + button.textContent = originalLabel; + } +} + +function closePhotoEditor() { + const overlay = document.getElementById('photo-editor-overlay'); + overlay.classList.add('hidden'); + overlay.setAttribute('aria-hidden', 'true'); + cancelCropMode(false); + photoEditorState.sourceImage = null; + photoEditorState.rootImage = null; + photoEditorState.versions = []; +} + + +// --- Photo Drag & Drop Sorting --- +function enablePhotoDragSort() { + const container = document.getElementById('photo-preview'); + let dragIdx = -1; + + container.addEventListener('dragstart', e => { + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + dragIdx = [...container.children].indexOf(thumb); + thumb.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const thumb = e.target.closest('.thumb'); + if (thumb) { + container.querySelectorAll('.thumb').forEach(t => t.classList.remove('drag-over')); + thumb.classList.add('drag-over'); + } + }); + + container.addEventListener('drop', e => { + e.preventDefault(); + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + const thumb = e.target.closest('.thumb'); + if (!thumb) return; + const dropIdx = [...container.children].indexOf(thumb); + if (dragIdx >= 0 && dropIdx >= 0 && dragIdx !== dropIdx) { + const moved = state.photos.splice(dragIdx, 1)[0]; + state.photos.splice(dropIdx, 0, moved); + renderPhotoPreview(); + } + dragIdx = -1; + }); + + container.addEventListener('dragend', () => { + container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); }); + dragIdx = -1; + }); +} + +// --- Price Suggestion --- +async function fetchPriceSuggestion() { + const title = document.getElementById('result-title')?.value; + const category = document.getElementById('meta-category-picker')?.textContent; + if (!title) return; + try { + const data = await api('GET', `/api/price-suggest?title=${encodeURIComponent(title)}&category=${encodeURIComponent(category || '')}`); + if (data.suggestion) { + const priceInput = document.getElementById('meta-price'); + const currentPrice = parseFloat(priceInput.value); + const hint = document.getElementById('price-suggestion-hint'); + const html = `Preisvorschlag: ${data.suggestion}€ (${data.min}–${data.max}€, ${data.count} ähnliche)`; + if (hint) { + hint.innerHTML = html; + hint.classList.remove('hidden'); + } else { + const el = document.createElement('div'); + el.id = 'price-suggestion-hint'; + el.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:4px;cursor:pointer'; + el.innerHTML = html; + el.addEventListener('click', () => { priceInput.value = data.suggestion; }); + priceInput.parentElement.appendChild(el); + } + } + } catch {} +} + +// --- Backup & Restore (now handled in setupAdmin for system tab) --- +function setupBackup() { + // Legacy backup buttons in settings - no longer exist, backup is in admin > system tab +} + +// --- Browser Notifications --- +function setupNotifications() { + if (!('Notification' in window)) return; + if (Notification.permission === 'default') { + Notification.requestPermission(); + } +} + +function notify(title, body) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + new Notification(title, { body, icon: '/favicon.ico' }); +} + +// --- Permissions & collaborative editing --- +function userCan(permission) { + return Boolean(currentUser?.role === 'admin' || currentUser?.permissions?.includes(permission)); +} + +function applyPermissionUi() { + const rules = [ + ['generator', 'listings.edit'], ['image-factory', 'batch.execute'], ['flux-studio', 'generation.execute'], + ['publish', 'publishing.view'], ['quality-center', 'listings.view'], ['inventory', 'listings.view'], + ]; + for (const [tab, permission] of rules) { + const button = document.querySelector(`.nav-item[data-tab="${tab}"]`); + if (button) button.classList.toggle('hidden', !userCan(permission)); + } + document.querySelectorAll('[data-requires-permission]').forEach(element => { + const permission = element.dataset.requiresPermission; + element.classList.toggle('hidden', !userCan(permission)); + if ('disabled' in element) element.disabled = !userCan(permission); + }); + document.body.dataset.userRole = currentUser?.role || 'viewer'; +} + +function clearListingLockTimer() { + if (listingEditLock.timer) window.clearInterval(listingEditLock.timer); + listingEditLock.timer = null; +} + +async function releaseListingLock({ quiet = true, keepalive = false } = {}) { + clearListingLockTimer(); + if (!listingEditLock.listingId || !listingEditLock.token) { + listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null; + return; + } + const id = listingEditLock.listingId; + const token = listingEditLock.token; + listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null; + try { + if (keepalive) { + await fetch(`/api/locks/listing/${id}`, { + method: 'DELETE', credentials: 'same-origin', keepalive: true, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ token }), + }); + } else await api('DELETE', `/api/locks/listing/${id}`, { token }, { retry: false, timeoutMs: 8000 }); + } catch (error) { if (!quiet) toast(error.message || 'Bearbeitungssperre konnte nicht gelöst werden.'); } +} + +function showListingLockBanner(lock, owned = false) { + document.getElementById('listing-lock-banner')?.remove(); + const detail = document.querySelector('.listing-editor-shell'); + if (!detail || !lock) return; + const banner = document.createElement('div'); + banner.id = 'listing-lock-banner'; + banner.className = `listing-lock-banner ${owned ? 'is-mine' : 'is-foreign'}`; + banner.innerHTML = owned + ? `
                                                                      Bearbeitung für dich reserviert

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

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

                                                                      Du kannst den Artikel ansehen. Speichern ist bis zum Ablauf der Sperre deaktiviert.

                                                                      `; + detail.prepend(banner); + const save = document.getElementById('detail-save'); + if (save && !owned) { save.disabled = true; save.title = 'Artikel ist durch einen anderen Benutzer gesperrt'; } +} + +async function acquireListingLock(listingId) { + await releaseListingLock({ quiet: true }); + if (!listingId || !userCan('listings.edit')) return { acquired: false, readOnly: true }; + try { + const result = await api('POST', `/api/locks/listing/${listingId}/acquire`, { client_label: `${navigator.platform || 'Browser'} · Artikel-Editor`, ttl_seconds: 120 }); + listingEditLock.listingId = Number(listingId); + listingEditLock.token = result.token; + listingEditLock.lock = result.lock; + showListingLockBanner(result.lock, true); + listingEditLock.timer = window.setInterval(async () => { + if (!listingEditLock.listingId || !listingEditLock.token) return; + try { + const renewed = await api('POST', `/api/locks/listing/${listingEditLock.listingId}/renew`, { token: listingEditLock.token, ttl_seconds: 120 }, { timeoutMs: 10000, retry: false }); + listingEditLock.lock = renewed.lock; + } catch (error) { + clearListingLockTimer(); + const save = document.getElementById('detail-save'); + if (save) save.disabled = true; + toast(`Bearbeitungssperre verloren: ${error.message}`); + } + }, 45000); + return { acquired: true, lock: result.lock }; + } catch (error) { + if (error.status === 409) { + showListingLockBanner(error.lock || { user_name: 'Ein anderer Benutzer' }, false); + return { acquired: false, readOnly: true, lock: error.lock }; + } + toast(`Bearbeitungssperre konnte nicht erstellt werden: ${error.message}`); + return { acquired: false, readOnly: true }; + } +} + +window.addEventListener('beforeunload', () => { releaseListingLock({ quiet: true, keepalive: true }); }); + +// --- User Menu --- +let currentUser = null; + +async function setupUserMenu() { + try { + currentUser = await api('GET', '/api/me'); + if (!currentUser || currentUser.setupMode) { + document.getElementById('user-menu').classList.add('hidden'); + return; + } + _csrfToken = currentUser.csrf || getCsrf(); + + // Populate sidebar user area + const initials = currentUser.initials || currentUser.name?.[0]?.toUpperCase() || '?'; + const sidebarAvatar = document.getElementById('sidebar-avatar'); + if (sidebarAvatar) { + sidebarAvatar.textContent = initials; + sidebarAvatar.style.background = currentUser.avatar_color || 'var(--accent)'; + } + const sidebarName = document.getElementById('sidebar-name'); + if (sidebarName) sidebarName.textContent = currentUser.name || currentUser.email || ''; + const sidebarRole = document.getElementById('sidebar-role'); + if (sidebarRole) sidebarRole.textContent = currentUser.role || ''; + const topbarAvatar = document.getElementById('topbar-avatar'); + if (topbarAvatar) { + topbarAvatar.textContent = initials; + topbarAvatar.style.background = currentUser.avatar_color || 'var(--vd-ink)'; + topbarAvatar.title = currentUser.name || currentUser.email || ''; + } + + const adminNav = document.getElementById('nav-admin-main'); + if (adminNav) adminNav.classList.toggle('hidden', !['admin', 'manager'].includes(currentUser.role)); + if (currentUser.role === 'manager') { + document.querySelectorAll('.admin-tab:not([data-admin-tab="audit"])').forEach(tab => tab.classList.add('hidden')); + document.getElementById('invite-user-btn')?.classList.add('hidden'); + adminNav?.setAttribute('data-admin-tab', 'audit'); + } + applyPermissionUi(); + + // Logout + document.getElementById('sidebar-logout-btn')?.addEventListener('click', async () => { + await releaseListingLock({ quiet: true }); + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken || getCsrf() } }); + window.location.href = '/login.html'; + }); + } catch { + document.getElementById('sidebar-user')?.classList.add('hidden'); + } +} + + +// --- Operations Center 1.30.0 --- +const operationsState = { status: null, backups: [], extensions: null, restoreFile: null, updateFile: null }; + +function operationFormatBytes(value) { + const bytes = Number(value || 0); + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / (1024 ** index)).toLocaleString('de-DE', { maximumFractionDigits: index ? 1 : 0 })} ${units[index]}`; +} + +function operationFormatDate(value) { + if (!value) return '—'; + try { return new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } + catch { return String(value); } +} + +async function operationsUploadRequest(url, field, file) { + const form = new FormData(); + form.append(field, file); + const response = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + return data; +} + +function renderOperationsStatus() { + const status = operationsState.status || {}; + const pending = status.pending; + const banner = document.getElementById('operations-pending-banner'); + if (banner) { + banner.classList.toggle('hidden', !pending); + if (pending) banner.innerHTML = `
                                                                      ${pending.type === 'update' ? `Update auf ${escapeHtml(pending.version_to || 'neue Version')}` : 'Wiederherstellung'} ist vorbereitet

                                                                      Vendoo beenden und setup.bat11 – Vorbereitete Operation anwenden ausführen.

                                                                      `; + } + document.getElementById('operations-pending-cancel')?.addEventListener('click', async () => { + if (!confirm('Vorbereitete Operation wirklich verwerfen?')) return; + try { await api('POST', '/api/operations/pending/cancel'); toast('Vorbereitung verworfen.'); await loadOperationsCenter(); } + catch (error) { toast(error.message || 'Vorbereitung konnte nicht verworfen werden.'); } + }); + + const version = document.getElementById('operations-version-card'); + if (version) { + const last = status.last; + version.innerHTML = `Vendoo ${escapeHtml(status.version || VENDOO_UI_BUILD)}${last ? `Letzte Operation: ${escapeHtml(last.type || 'System')} · ${escapeHtml(last.status || 'unbekannt')}` : 'Noch keine Setup-Operation protokolliert.'}`; + } + const migrations = document.getElementById('operations-migration-list'); + if (migrations) { + const rows = status.migrations || []; + migrations.innerHTML = rows.length ? rows.slice(0, 8).map(row => `

                                                                      ${escapeHtml(row.app_version)}${escapeHtml(row.description || row.migration_key)} · ${operationFormatDate(row.applied_at)}

                                                                      `).join('') : '
                                                                      Keine Migrationsinformationen vorhanden.
                                                                      '; + } + + const settings = status.settings || {}; + const enabled = document.getElementById('operations-auto-enabled'); if (enabled) enabled.checked = !!settings.auto_backup_enabled; + const hour = document.getElementById('operations-auto-hour'); if (hour) hour.value = String(settings.auto_backup_hour ?? 3); + const retention = document.getElementById('operations-auto-retention'); if (retention) retention.value = String(settings.auto_backup_retention ?? 7); + const uploads = document.getElementById('operations-auto-uploads'); if (uploads) uploads.checked = settings.include_uploads_automatic !== false; + const updateUrl = document.getElementById('operations-update-url'); if (updateUrl && document.activeElement !== updateUrl) updateUrl.value = settings.update_manifest_url || ''; + window.VendooIcons?.render(document.getElementById('admin-system-panel')); +} + +function renderOperationsBackups() { + const list = document.getElementById('operations-backup-list'); + const summary = document.getElementById('operations-backup-summary'); + if (!list) return; + const backups = operationsState.backups || []; + const total = backups.reduce((sum, item) => sum + Number(item.file_size || 0), 0); + if (summary) summary.textContent = `${backups.length} Sicherung(en) · ${operationFormatBytes(total)}`; + if (!backups.length) { + list.innerHTML = '
                                                                      Noch kein Backup vorhanden.
                                                                      '; + return; + } + list.innerHTML = backups.map(item => { + const verified = item.status === 'verified'; + const includesUploads = item.options?.includeUploads; + return `
                                                                      +
                                                                      +
                                                                      ${escapeHtml(item.label || item.file_name)}${escapeHtml(item.kind === 'automatic' ? 'Automatisch' : item.kind === 'database' ? 'Nur Datenbank' : 'Manuell')} · ${operationFormatDate(item.created_at)}${operationFormatBytes(item.file_size)} · ${includesUploads ? 'mit Uploads' : 'ohne Uploads'}${item.options?.includeConfig ? ' · enthält .env' : ''} · ${verified ? 'verifiziert' : escapeHtml(item.status)}
                                                                      + ${escapeHtml(String(item.sha256 || '').slice(0, 16))}… +
                                                                      + + + +
                                                                      +
                                                                      `; + }).join(''); + list.querySelectorAll('[data-operation-backup-verify]').forEach(button => button.addEventListener('click', async () => { + button.disabled = true; + try { + const result = await api('POST', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupVerify)}/verify`); + toast(result.ok ? 'Backup vollständig verifiziert.' : `Backup fehlerhaft: ${(result.errors || []).join(', ')}`); + await loadOperationsBackups(); + } catch (error) { toast(error.message); } + finally { button.disabled = false; } + })); + list.querySelectorAll('[data-operation-backup-delete]').forEach(button => button.addEventListener('click', async () => { + if (!confirm('Dieses Backup endgültig löschen?')) return; + try { await api('DELETE', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupDelete)}`); toast('Backup gelöscht.'); await loadOperationsBackups(); } + catch (error) { toast(error.message); } + })); + window.VendooIcons?.render(list); +} + +function renderOperationsExtensions() { + const data = operationsState.extensions || {}; + const summary = document.getElementById('operations-extension-summary'); + const packages = document.getElementById('operations-extension-packages'); + const clients = document.getElementById('operations-extension-clients'); + if (summary) summary.innerHTML = `
                                                                      ${Number(data.connected || 0)} Browser live verbunden

                                                                      Erwartete Extension-Version: ${escapeHtml(data.expected_version || '—')}

                                                                      `; + if (packages) packages.innerHTML = Object.entries(data.packages || {}).map(([browser, item]) => `
                                                                      ${escapeHtml(browser[0].toUpperCase() + browser.slice(1))}${item.exists ? `Paket v${escapeHtml(item.version || '?')}` : 'Paket fehlt'}${item.version && item.version !== data.expected_version ? ` · erwartet ${escapeHtml(data.expected_version)}` : ''}
                                                                      `).join(''); + if (clients) { + const rows = data.clients || []; + clients.innerHTML = rows.length ? rows.map(client => { + const live = Number(client.age_seconds || 999999) <= 180; + return `
                                                                      ${escapeHtml(client.browser || 'Browser')} · v${escapeHtml(client.version || '?')}${escapeHtml(client.platform || 'Extension')} · zuletzt ${Number(client.age_seconds || 0) < 60 ? 'gerade eben' : `vor ${Math.max(1, Math.round(Number(client.age_seconds || 0) / 60))} Min.`}${escapeHtml(client.server_url || '')}
                                                                      ${live ? 'Live' : 'Offline'}
                                                                      `; + }).join('') : '
                                                                      Noch keine Extension-Verbindung gemeldet. Öffne nach dem Update die Extension oder eine unterstützte Vinted-Seite.
                                                                      '; + } + window.VendooIcons?.render(document.getElementById('operations-extensions-panel')); +} + +async function loadOperationsBackups() { + try { operationsState.backups = (await api('GET', '/api/operations/backups')).backups || []; renderOperationsBackups(); } + catch (error) { const list = document.getElementById('operations-backup-list'); if (list) list.innerHTML = `
                                                                      ${escapeHtml(error.message)}
                                                                      `; } +} + +async function loadOperationsExtensions() { + try { operationsState.extensions = await api('GET', '/api/extensions/diagnostics'); renderOperationsExtensions(); } + catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `
                                                                      ${escapeHtml(error.message)}
                                                                      `; } +} + +async function loadOperationsCenter() { + if (!document.getElementById('operations-center-title')) return; + try { operationsState.status = await api('GET', '/api/operations/status'); renderOperationsStatus(); } catch (error) { toast(error.message); } + await Promise.all([loadOperationsBackups(), loadOperationsExtensions()]); +} + +function setOperationsTab(target) { + document.querySelectorAll('.operations-tab').forEach(button => button.classList.toggle('active', button.dataset.operationsTab === target)); + document.querySelectorAll('.operations-panel').forEach(panel => panel.classList.toggle('active', panel.id === `operations-${target}-panel`)); +} + +function setupOperationsCenter() { + const hour = document.getElementById('operations-auto-hour'); + if (hour && !hour.options.length) hour.innerHTML = Array.from({ length: 24 }, (_, index) => ``).join(''); + document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', () => setOperationsTab(button.dataset.operationsTab))); + document.getElementById('operations-refresh-btn')?.addEventListener('click', loadOperationsCenter); + document.getElementById('operations-extension-refresh')?.addEventListener('click', loadOperationsExtensions); + + document.getElementById('operations-backup-create')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; const old = button.innerHTML; button.textContent = 'Backup wird erstellt und geprüft …'; + try { + const result = await api('POST', '/api/operations/backups', { + label: document.getElementById('operations-backup-label')?.value || '', + include_uploads: document.getElementById('operations-backup-uploads')?.checked !== false, + include_config: document.getElementById('operations-backup-config')?.checked === true, + }); + toast(`Backup verifiziert: ${result.filename}`); + document.getElementById('operations-backup-label').value = ''; + await loadOperationsBackups(); + } catch (error) { toast(error.message || 'Backup fehlgeschlagen.'); } + finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); } + }); + + document.getElementById('operations-settings-save')?.addEventListener('click', async () => { + try { + operationsState.status.settings = await api('PUT', '/api/operations/settings', { + auto_backup_enabled: document.getElementById('operations-auto-enabled')?.checked === true, + auto_backup_hour: Number(document.getElementById('operations-auto-hour')?.value || 3), + auto_backup_retention: Number(document.getElementById('operations-auto-retention')?.value || 7), + include_uploads_automatic: document.getElementById('operations-auto-uploads')?.checked !== false, + }); + renderOperationsStatus(); toast('Backup-Zeitplan gespeichert.'); + } catch (error) { toast(error.message); } + }); + + const restoreInput = document.getElementById('operations-restore-input'); + restoreInput?.addEventListener('change', () => { + operationsState.restoreFile = restoreInput.files?.[0] || null; + document.getElementById('operations-restore-file').textContent = operationsState.restoreFile ? `${operationsState.restoreFile.name} · ${operationFormatBytes(operationsState.restoreFile.size)}` : 'Keine Datei ausgewählt.'; + document.getElementById('operations-restore-stage').disabled = !operationsState.restoreFile; + }); + document.getElementById('operations-restore-stage')?.addEventListener('click', async event => { + if (!operationsState.restoreFile || !confirm('Backup vollständig prüfen und Wiederherstellung vorbereiten? Es wird automatisch ein Sicherheitsbackup erstellt.')) return; + const button = event.currentTarget; button.disabled = true; button.textContent = 'Backup wird geprüft …'; + try { const result = await operationsUploadRequest('/api/operations/restore/stage', 'backup', operationsState.restoreFile); toast(result.message); operationsState.restoreFile = null; restoreInput.value = ''; await loadOperationsCenter(); } + catch (error) { toast(error.message); } + finally { button.disabled = !operationsState.restoreFile; button.textContent = 'Wiederherstellung prüfen und vorbereiten'; } + }); + + document.getElementById('operations-update-url-save')?.addEventListener('click', async () => { + try { operationsState.status.settings = await api('PUT', '/api/operations/settings', { update_manifest_url: document.getElementById('operations-update-url')?.value || '' }); renderOperationsStatus(); toast('Update-Manifest-URL gespeichert.'); } + catch (error) { toast(error.message); } + }); + document.getElementById('operations-update-check')?.addEventListener('click', async event => { + const resultBox = document.getElementById('operations-update-check-result'); const button = event.currentTarget; button.disabled = true; if (resultBox) resultBox.textContent = 'Update wird geprüft …'; + try { + const result = await api('POST', '/api/operations/updates/check'); + if (resultBox) resultBox.innerHTML = result.configured ? `${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : 'Vendoo ist aktuell'}Installiert: ${escapeHtml(result.current_version)}${result.latest_version ? ` · Verfügbar: ${escapeHtml(result.latest_version)}` : ''}` : `Keine Online-Prüfung konfiguriert${escapeHtml(result.message || '')}`; + } catch (error) { if (resultBox) resultBox.innerHTML = `Prüfung fehlgeschlagen${escapeHtml(error.message)}`; } + finally { button.disabled = false; } + }); + + const updateInput = document.getElementById('operations-update-input'); + updateInput?.addEventListener('change', () => { + operationsState.updateFile = updateInput.files?.[0] || null; + document.getElementById('operations-update-file').textContent = operationsState.updateFile ? `${operationsState.updateFile.name} · ${operationFormatBytes(operationsState.updateFile.size)}` : 'Keine Datei ausgewählt.'; + document.getElementById('operations-update-stage').disabled = !operationsState.updateFile; + }); + document.getElementById('operations-update-stage')?.addEventListener('click', async event => { + if (!operationsState.updateFile || !confirm('Update prüfen, Sicherheitsbackup erzeugen und für den nächsten Setup-Lauf vorbereiten?')) return; + const button = event.currentTarget; button.disabled = true; button.textContent = 'Update wird geprüft …'; + try { const result = await operationsUploadRequest('/api/operations/updates/stage', 'update', operationsState.updateFile); toast(result.message); operationsState.updateFile = null; updateInput.value = ''; await loadOperationsCenter(); } + catch (error) { toast(error.message); } + finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; } + }); +} + +// --- Admin --- +function setupAdmin() { + // Admin tab switching + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', () => { + const target = tab.dataset.adminTab; + switchAdminTab(target); + if (target === 'users') loadAdminUsers(); + if (target === 'roles') loadRoleMatrix(); + if (target === 'sessions') loadAdminSessions(); + if (target === 'smtp') loadSmtpSettings(); + if (target === 'system') { loadSystemInfo(); loadOperationsCenter(); } + if (target === 'security') loadSecurityCenter(); + if (target === 'audit') loadAdminAudit(); + }); + }); + + document.getElementById('invite-user-btn')?.addEventListener('click', () => { + document.getElementById('invite-email').value = ''; + document.getElementById('invite-name').value = ''; + document.getElementById('invite-role').value = 'editor'; + document.getElementById('invite-result-wrap').classList.add('hidden'); + document.getElementById('invite-result-password').value = ''; + const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]'); + if (mlRadio) mlRadio.checked = true; + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + document.getElementById('invite-modal').classList.remove('hidden'); + }); + + document.getElementById('invite-copy-pw')?.addEventListener('click', () => { + const pw = document.getElementById('invite-result-password').value; + if (pw) { navigator.clipboard.writeText(pw); toast('Passwort kopiert!'); } + }); + + document.getElementById('invite-send-btn')?.addEventListener('click', sendInvite); + document.getElementById('edit-user-save-btn')?.addEventListener('click', saveUserEdit); + + // SMTP + document.getElementById('smtp-save-btn')?.addEventListener('click', saveSmtpSettings); + document.getElementById('smtp-test-btn')?.addEventListener('click', testSmtpConnection); + document.getElementById('smtp-pass-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('smtp-pass'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('smtp-pass-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // Edit user password toggle + document.getElementById('edit-user-pw-toggle')?.addEventListener('click', () => { + const inp = document.getElementById('edit-user-password'); + const isPw = inp.type === 'password'; + inp.type = isPw ? 'text' : 'password'; + document.getElementById('edit-user-pw-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen'; + }); + + // System tab + document.getElementById('system-cleanup-btn')?.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/admin/sessions/cleanup'); + toast(res.message || 'Sessions bereinigt'); + loadSystemInfo(); + } catch (err) { toast('Fehler: ' + err.message); } + }); + + document.getElementById('system-diagnostics-btn')?.addEventListener('click', runSystemDiagnostics); + document.getElementById('system-diagnostics-copy')?.addEventListener('click', copySystemDiagnostics); + document.getElementById('security-refresh-btn')?.addEventListener('click', loadSecurityCenter); + document.getElementById('audit-filter-btn')?.addEventListener('click', () => { auditState.page = 1; loadAdminAudit(); }); + document.getElementById('audit-search')?.addEventListener('keydown', event => { if (event.key === 'Enter') { auditState.page = 1; loadAdminAudit(); } }); + document.getElementById('audit-prev')?.addEventListener('click', () => { if (auditState.page > 1) { auditState.page -= 1; loadAdminAudit(); } }); + document.getElementById('audit-next')?.addEventListener('click', () => { if (auditState.page < auditState.pages) { auditState.page += 1; loadAdminAudit(); } }); + document.getElementById('audit-export-btn')?.addEventListener('click', exportAdminAudit); + + setupOperationsCenter(); +} + +function switchAdminTab(target) { + document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.adminTab === target)); + document.querySelectorAll('.admin-panel').forEach(p => p.classList.remove('active')); + document.getElementById(`admin-${target}-panel`)?.classList.add('active'); +} + +function timeAgo(dateStr) { + if (!dateStr) return 'Nie'; + const diff = Date.now() - new Date(dateStr + 'Z').getTime(); + const min = Math.floor(diff / 60000); + if (min < 1) return 'Gerade eben'; + if (min < 60) return `Vor ${min}m`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `Vor ${hrs}h`; + const days = Math.floor(hrs / 24); + return `Vor ${days}d`; +} + +function getInitials(name, email) { + const src = name || email || '?'; + return src.split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); +} + +const ROLE_LABELS = { admin: 'Administrator', manager: 'Manager', editor: 'Editor', publisher: 'Publisher', viewer: 'Leser' }; +let adminUsersCache = []; + +async function loadRoleMatrix() { + const cards = document.getElementById('role-cards'); + const matrix = document.getElementById('role-permission-matrix'); + if (!cards || !matrix) return; + try { + const data = await api('GET', '/api/security/roles'); + cards.innerHTML = data.roles.map(role => `
                                                                      ${escapeHtml(role.label)}${role.permissions.length} Rechte

                                                                      ${escapeHtml(role.description)}

                                                                      `).join(''); + const groups = [...new Set(data.permissions.map(permission => permission.group))]; + matrix.innerHTML = groups.map(group => { + const permissions = data.permissions.filter(permission => permission.group === group); + return `

                                                                      ${escapeHtml(group)}

                                                                      Berechtigung${data.roles.map(role => `${escapeHtml(role.label)}`).join('')}
                                                                      ${permissions.map(permission => `
                                                                      ${escapeHtml(permission.label)}${data.roles.map(role => `${role.permissions.includes(permission.key) ? '✓' : '—'}`).join('')}
                                                                      `).join('')}
                                                                      `; + }).join(''); + } catch (error) { + cards.innerHTML = `
                                                                      ${escapeHtml(error.message)}
                                                                      `; + matrix.innerHTML = ''; + } +} + +async function forceReleaseSecurityLock(type, id) { + if (!confirm('Bearbeitungssperre wirklich administrativ aufheben?')) return; + try { await api('DELETE', `/api/security/locks/${encodeURIComponent(type)}/${encodeURIComponent(id)}`); toast('Sperre aufgehoben'); loadSecurityCenter(); } + catch (error) { toast(error.message); } +} + +async function loadSecurityCenter() { + const summary = document.getElementById('security-status-summary'); + if (!summary) return; + summary.innerHTML = '
                                                                      Sicherheitsstatus wird geladen …
                                                                      '; + try { + const status = await api('GET', '/api/security/status'); + const secure = !status.remote_binding || status.secure_public_url; + summary.innerHTML = `
                                                                      ${secure && !status.warnings.length ? 'Sicherheitsbasis aktiv' : 'Konfiguration braucht Aufmerksamkeit'}

                                                                      Vendoo ${escapeHtml(status.version)} · Bind ${escapeHtml(status.bind_host)}:${status.port} · ${status.trust_proxy ? 'Reverse-Proxy vertraut' : 'Direkte Verbindung'}

                                                                      `; + const warnings = document.getElementById('security-warning-list'); + warnings.innerHTML = status.warnings.length ? status.warnings.map(message => `

                                                                      ${escapeHtml(message)}

                                                                      `).join('') : '
                                                                      Keine kritischen Konfigurationswarnungen erkannt.
                                                                      '; + document.getElementById('security-network-info').innerHTML = `
                                                                      Bind-Adresse
                                                                      ${escapeHtml(status.bind_host)}
                                                                      Öffentliche URL
                                                                      ${status.public_base_url ? `${escapeHtml(status.public_base_url)}` : 'Nicht gesetzt'}
                                                                      HTTPS
                                                                      ${status.secure_public_url ? 'Aktiv' : 'Nicht erkannt'}
                                                                      Erlaubte Hosts
                                                                      ${status.allowed_hosts.length ? status.allowed_hosts.map(escapeHtml).join(', ') : 'Nur lokale/private Hosts'}
                                                                      Erlaubte Origins
                                                                      ${status.allowed_origins.length ? status.allowed_origins.map(escapeHtml).join(', ') : 'Nur Same-Origin und Extensions'}
                                                                      `; + document.getElementById('security-session-info').innerHTML = `
                                                                      Absolute Sessiondauer
                                                                      ${status.session_days} Tage
                                                                      Inaktivitätslimit
                                                                      ${status.session_idle_minutes} Minuten
                                                                      Rate-Limit erlaubt
                                                                      ${status.rate_limits.allowed}
                                                                      Rate-Limit blockiert
                                                                      ${status.rate_limits.blocked}
                                                                      Aktive Sperren
                                                                      ${status.active_locks.length}
                                                                      `; + const locks = document.getElementById('security-lock-list'); + locks.innerHTML = status.active_locks.length ? status.active_locks.map(lock => `
                                                                      ${escapeHtml(lock.resource_type)} #${escapeHtml(lock.resource_id)}

                                                                      ${escapeHtml(lock.user_name || lock.user_email)} · bis ${new Date(lock.expires_at).toLocaleTimeString('de-DE')}

                                                                      `).join('') : '
                                                                      Keine aktiven Bearbeitungssperren.
                                                                      '; + locks.querySelectorAll('[data-force-lock-type]').forEach(button => button.addEventListener('click', () => forceReleaseSecurityLock(button.dataset.forceLockType, button.dataset.forceLockId))); + globalThis.VendooIcons?.render?.(); + } catch (error) { summary.innerHTML = `
                                                                      ${escapeHtml(error.message)}
                                                                      `; } +} + +const auditState = { page: 1, pages: 1, total: 0 }; + +function buildAuditQuery() { + const size = Number(document.getElementById('audit-page-size')?.value || 100); + const params = new URLSearchParams({ limit: String(size), offset: String((auditState.page - 1) * size) }); + const values = { + q: document.getElementById('audit-search')?.value.trim(), + action: document.getElementById('audit-action-filter')?.value.trim(), + from: document.getElementById('audit-from')?.value, + to: document.getElementById('audit-to')?.value ? `${document.getElementById('audit-to').value} 23:59:59` : '', + }; + for (const [key, value] of Object.entries(values)) if (value) params.set(key, value); + return params; +} + +function exportAdminAudit() { + const params = buildAuditQuery(); + params.delete('limit'); params.delete('offset'); + window.location.href = `/api/admin/audit/export.csv?${params.toString()}`; +} + +async function loadAdminUsers() { + try { + const users = await api('GET', '/api/admin/users'); + adminUsersCache = Array.isArray(users) ? users : []; + const container = document.getElementById('users-list'); + if (!container) return; + if (!adminUsersCache.length) { container.innerHTML = '
                                                                      Keine User vorhanden
                                                                      '; return; } + + container.innerHTML = adminUsersCache.map(u => { + const initials = getInitials(u.name, u.email); + const isOnline = u.last_active_at && (Date.now() - new Date(`${u.last_active_at}Z`).getTime()) < 5 * 60 * 1000; + const dotClass = !u.active ? 'inactive' : isOnline ? 'online' : 'offline'; + const lockedBadge = u.locked_until && Date.parse(u.locked_until) > Date.now() ? 'Vorübergehend gesperrt' : ''; + const pwBadge = u.has_password + ? 'Passwort gesetzt' + : 'Kein Passwort'; + return `
                                                                      +
                                                                      ${escapeHtml(initials)}
                                                                      +
                                                                      +
                                                                      ${escapeHtml(u.name || u.email)} ${escapeHtml(ROLE_LABELS[u.role] || u.role)} ${pwBadge} ${lockedBadge}
                                                                      + +
                                                                      ${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(`${u.created_at}Z`).toLocaleDateString('de-DE')}
                                                                      +
                                                                      +
                                                                      + + + ${u.id !== currentUser?.id ? `` : ''} +
                                                                      +
                                                                      `; + }).join(''); + + container.querySelectorAll('[data-user-edit]').forEach(button => button.addEventListener('click', () => openEditUser(Number(button.dataset.userEdit)))); + container.querySelectorAll('[data-user-resend]').forEach(button => button.addEventListener('click', () => resendInvite(Number(button.dataset.userResend)))); + container.querySelectorAll('[data-user-delete]').forEach(button => button.addEventListener('click', () => { + const user = adminUsersCache.find(entry => Number(entry.id) === Number(button.dataset.userDelete)); + if (user) confirmDeleteUser(user.id, user.email); + })); + globalThis.VendooIcons?.render?.(container); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function sendInvite() { + const email = document.getElementById('invite-email').value.trim(); + const name = document.getElementById('invite-name').value.trim(); + const role = document.getElementById('invite-role').value; + const method = document.querySelector('input[name="invite-method"]:checked')?.value || 'magic_link'; + if (!email) return toast('E-Mail erforderlich'); + + try { + const result = await api('POST', '/api/admin/users/invite', { email, name, role, method }); + + if (method === 'auto_password' && result.password) { + // Show generated password + const resultWrap = document.getElementById('invite-result-wrap'); + const pwInput = document.getElementById('invite-result-password'); + pwInput.value = result.password; + resultWrap.classList.remove('hidden'); + document.getElementById('invite-send-btn').textContent = 'Fertig'; + document.getElementById('invite-send-btn').onclick = () => { + document.getElementById('invite-modal').classList.add('hidden'); + resultWrap.classList.add('hidden'); + document.getElementById('invite-send-btn').textContent = 'Einladung senden'; + document.getElementById('invite-send-btn').onclick = sendInvite; + }; + toast(result.consoleFallback ? 'Account erstellt — Zugangsdaten in Konsole' : 'Account erstellt & Zugangsdaten gesendet'); + } else { + document.getElementById('invite-modal').classList.add('hidden'); + if (result.consoleFallback) { + toast('Einladung erstellt — Link in Server-Konsole'); + } else { + toast('Einladung gesendet an ' + email); + } + } + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function resendInvite(userId) { + try { + const result = await api('POST', '/api/admin/users/resend-invite', { user_id: userId }); + if (result.consoleFallback) { + toast('Link in Server-Konsole ausgegeben'); + } else { + toast('Einladung erneut gesendet'); + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function openEditUser(userId) { + try { + let user = adminUsersCache.find(entry => Number(entry.id) === Number(userId)); + if (!user) { + const users = await api('GET', '/api/admin/users'); + adminUsersCache = Array.isArray(users) ? users : []; + user = adminUsersCache.find(entry => Number(entry.id) === Number(userId)); + } + if (!user) throw new Error('Benutzer wurde nicht gefunden.'); + document.getElementById('edit-user-id').value = user.id; + const emailField = document.getElementById('edit-user-email'); + if (emailField) emailField.value = user.email || ''; + document.getElementById('edit-user-name').value = user.name || ''; + document.getElementById('edit-user-role').value = user.role; + document.getElementById('edit-user-active').value = user.active ? '1' : '0'; + document.getElementById('edit-user-password').value = ''; + document.getElementById('edit-user-password').type = 'password'; + document.getElementById('edit-user-pw-toggle').textContent = 'Zeigen'; + + // Password status + const pwStatus = document.getElementById('edit-user-pw-status'); + if (user.has_password) { + pwStatus.textContent = 'Passwort gesetzt'; + pwStatus.className = 'pw-status-badge has-pw'; + } else { + pwStatus.textContent = 'Kein Passwort'; + pwStatus.className = 'pw-status-badge no-pw'; + } + + // Login history + const historyEl = document.getElementById('edit-user-login-history'); + historyEl.classList.add('hidden'); + historyEl.innerHTML = ''; + + const showHistBtn = document.getElementById('edit-user-show-history'); + showHistBtn.onclick = async () => { + if (!historyEl.classList.contains('hidden')) { + historyEl.classList.add('hidden'); + showHistBtn.textContent = 'Login-Verlauf anzeigen'; + return; + } + try { + const history = await api('GET', `/api/admin/users/${userId}/login-history`); + if (!history.length) { + historyEl.innerHTML = '
                                                                      Keine Login-Einträge
                                                                      '; + } else { + historyEl.innerHTML = history.map(h => { + const dt = new Date(h.created_at + 'Z').toLocaleString('de-DE'); + const statusClass = h.success ? 'success' : 'failed'; + const statusText = h.success ? 'Erfolgreich' : 'Fehlgeschlagen'; + return ``; + }).join(''); + } + historyEl.classList.remove('hidden'); + showHistBtn.textContent = 'Verlauf ausblenden'; + } catch (err) { toast('Fehler: ' + err.message); } + }; + + document.getElementById('edit-user-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveUserEdit() { + const id = Number(document.getElementById('edit-user-id').value); + const name = document.getElementById('edit-user-name').value.trim(); + const role = document.getElementById('edit-user-role').value; + const active = Number(document.getElementById('edit-user-active').value); + const password = document.getElementById('edit-user-password').value; + const button = document.getElementById('edit-user-save-btn'); + + if (!Number.isInteger(id) || id <= 0) return toast('Ungültiger Benutzer. Bitte die Liste neu laden.'); + if (!name) return toast('Bitte einen Namen eintragen.'); + if (password && (password.length < 10 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password) || !/[^A-Za-z0-9]/.test(password))) { + return toast('Passwort braucht mindestens 10 Zeichen, Groß-/Kleinbuchstaben, Zahl und Sonderzeichen'); + } + + const oldLabel = button?.textContent || 'Speichern'; + if (button) { button.disabled = true; button.textContent = 'Wird gespeichert …'; } + try { + const updated = await api('PUT', `/api/admin/users/${id}`, { name, role, active }); + if (password) await api('POST', `/api/admin/users/${id}/reset-password`, { password }); + adminUsersCache = adminUsersCache.map(entry => Number(entry.id) === id ? { ...entry, ...updated, has_password: password ? 1 : entry.has_password } : entry); + toast(password ? 'Benutzer aktualisiert und Passwort zurückgesetzt.' : 'Benutzer aktualisiert.'); + document.getElementById('edit-user-modal').classList.add('hidden'); + await loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } + finally { if (button) { button.disabled = false; button.textContent = oldLabel; } } +} + +async function confirmDeleteUser(userId, email) { + if (!confirm(`User "${email}" wirklich löschen? Alle Sessions werden beendet.`)) return; + try { + await api('DELETE', `/api/admin/users/${userId}`); + toast('User gelöscht'); + loadAdminUsers(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminSessions() { + try { + const sessions = await api('GET', '/api/admin/sessions'); + const container = document.getElementById('sessions-list'); + if (!sessions.length) { container.innerHTML = '
                                                                      Keine aktiven Sessions
                                                                      '; return; } + + container.innerHTML = sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                                                                      +
                                                                      + ${s.name || s.email} ${s.role} +
                                                                      ${ua}
                                                                      +
                                                                      IP: ${s.ip || 'unbekannt'}
                                                                      +
                                                                      +
                                                                      + ${timeAgo(s.created_at)}
                                                                      + Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')} +
                                                                      + +
                                                                      `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function killSession(sessionId) { + try { + await api('DELETE', `/api/admin/sessions/${sessionId}`); + toast('Session beendet'); + loadAdminSessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadAdminAudit() { + try { + const params = buildAuditQuery(); + const result = await api('GET', `/api/admin/audit?${params.toString()}`); + const logs = result.rows || []; + const size = Number(result.limit || 100); + auditState.total = Number(result.total || 0); + auditState.pages = Math.max(1, Math.ceil(auditState.total / size)); + if (auditState.page > auditState.pages) auditState.page = auditState.pages; + const container = document.getElementById('audit-list'); + document.getElementById('audit-summary').textContent = `${auditState.total.toLocaleString('de-DE')} protokollierte Ereignisse`; + document.getElementById('audit-page-info').textContent = `Seite ${auditState.page} von ${auditState.pages}`; + document.getElementById('audit-prev').disabled = auditState.page <= 1; + document.getElementById('audit-next').disabled = auditState.page >= auditState.pages; + if (!logs.length) { container.innerHTML = '
                                                                      Keine passenden Audit-Einträge
                                                                      '; return; } + container.innerHTML = logs.map(l => { + const actionParts = (l.action || '').split('.'); + const category = actionParts[0] || 'other'; + return `
                                                                      ${new Date(String(l.created_at).replace(' ', 'T') + 'Z').toLocaleString('de-DE')}
                                                                      ${escapeHtml(l.user_email || 'System')}${escapeHtml(l.ip || '')}
                                                                      ${escapeHtml(l.action)}${l.target_type ? `${escapeHtml(l.target_type)} ${escapeHtml(l.target_id || '')}` : ''}${l.details ? `

                                                                      ${escapeHtml(l.details)}

                                                                      ` : ''}
                                                                      `; + }).join(''); + } catch (err) { toast('Fehler: ' + err.message); } +} + +// --- SMTP Settings --- +async function loadSmtpSettings() { + try { + const smtp = await api('GET', '/api/admin/smtp'); + document.getElementById('smtp-host').value = smtp.smtp_host || ''; + document.getElementById('smtp-port').value = smtp.smtp_port || '587'; + document.getElementById('smtp-user').value = smtp.smtp_user || ''; + document.getElementById('smtp-pass').value = ''; + document.getElementById('smtp-pass').placeholder = smtp.smtp_pass ? 'Gesetzt (leer = beibehalten)' : 'Passwort'; + document.getElementById('smtp-from').value = smtp.smtp_from || ''; + + const dot = document.getElementById('smtp-status-dot'); + const text = document.getElementById('smtp-status-text'); + if (smtp.configured) { + dot.className = 'smtp-status-dot configured'; + text.textContent = `SMTP konfiguriert (Quelle: ${smtp.source === 'db' ? 'Datenbank' : smtp.source === 'env' ? '.env Datei' : 'Nicht konfiguriert'})`; + } else { + dot.className = 'smtp-status-dot not-configured'; + text.textContent = 'SMTP nicht konfiguriert — E-Mails werden in der Konsole ausgegeben'; + } + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function saveSmtpSettings() { + const data = { + smtp_host: document.getElementById('smtp-host').value.trim(), + smtp_port: document.getElementById('smtp-port').value.trim(), + smtp_user: document.getElementById('smtp-user').value.trim(), + smtp_from: document.getElementById('smtp-from').value.trim(), + }; + const pass = document.getElementById('smtp-pass').value; + if (pass) data.smtp_pass = pass; + + try { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = true; btn.textContent = 'Speichere...'; + await api('PUT', '/api/admin/smtp', data); + toast('SMTP-Einstellungen gespeichert'); + loadSmtpSettings(); + } catch (err) { toast('Fehler: ' + err.message); } + finally { + const btn = document.getElementById('smtp-save-btn'); + btn.disabled = false; btn.textContent = 'SMTP speichern'; + } +} + +async function testSmtpConnection() { + const btn = document.getElementById('smtp-test-btn'); + btn.disabled = true; btn.textContent = 'Sende Testmail...'; + try { + const res = await api('POST', '/api/admin/smtp/test'); + toast(res.message || 'Testmail gesendet!'); + } catch (err) { toast('Fehler: ' + err.message); } + finally { btn.disabled = false; btn.textContent = 'Verbindung testen'; } +} + + +// --- Batch Image Factory --- +let imageFactoryPollTimer = null; +let imageFactoryEditingProfileId = null; + +function imageFactoryStatusMeta(status) { + return ({ + queued: ['Wartend', 'queued'], running: ['Läuft', 'running'], paused: ['Pausiert', 'paused'], + completed: ['Fertig', 'completed'], partial: ['Teilweise fertig', 'partial'], failed: ['Fehlgeschlagen', 'failed'], + cancelled: ['Abgebrochen', 'cancelled'], + })[status] || [status || 'Unbekannt', 'queued']; +} + +function updateImageFactorySelection() { + const sources = state.imageFactorySelectedSources.size; + const profiles = state.imageFactorySelectedProfiles.size; + const outputs = sources * profiles; + document.getElementById('image-factory-selection-count')?.replaceChildren(document.createTextNode(`${sources} Bilder · ${profiles} Profile`)); + document.getElementById('image-factory-output-estimate')?.replaceChildren(document.createTextNode(outputs ? `${outputs} neue Bilddateien werden erzeugt` : 'Noch keine Ausgabe geplant')); + const submit = document.getElementById('image-factory-submit'); + if (submit) submit.disabled = !sources || !profiles; +} + +function renderImageFactorySources() { + const container = document.getElementById('image-factory-source-grid'); + if (!container) return; + if (!state.imageFactorySources.length) { + container.innerHTML = '
                                                                      Keine passenden Bilder gefunden. Passe Suche oder Quellenfilter an.
                                                                      '; + return; + } + container.innerHTML = state.imageFactorySources.map(item => { + const selected = state.imageFactorySelectedSources.has(item.image_path); + const title = item.title || item.prompt || item.image_path.split('/').pop(); + return ``; + }).join(''); + container.querySelectorAll('[data-image-factory-source]').forEach(input => input.addEventListener('change', () => { + if (input.checked) state.imageFactorySelectedSources.add(input.dataset.imageFactorySource); + else state.imageFactorySelectedSources.delete(input.dataset.imageFactorySource); + input.closest('.image-factory-source-card')?.classList.toggle('is-selected', input.checked); + updateImageFactorySelection(); + })); +} + +function profileDimensionLabel(profile) { + const resize = profile.operations?.resize || {}; + const dimensions = resize.width || resize.height ? `${resize.width || 'auto'} × ${resize.height || 'auto'}` : 'Originalgröße'; + return `${dimensions} · ${(profile.operations?.format || 'jpeg').toUpperCase()} · Q${profile.operations?.quality || 90}`; +} + +function renderImageFactoryProfiles() { + const container = document.getElementById('image-factory-profile-grid'); + if (!container) return; + if (!state.imageFactoryProfiles.length) { + container.innerHTML = '
                                                                      Keine Produktionsprofile vorhanden.
                                                                      '; + return; + } + container.innerHTML = state.imageFactoryProfiles.map(profile => { + const selected = state.imageFactorySelectedProfiles.has(Number(profile.id)); + return `
                                                                      + +
                                                                      ${profile.is_builtin ? 'P' : 'E'}
                                                                      +
                                                                      ${escapeHtml(profile.name)}

                                                                      ${escapeHtml(profile.description || 'Eigenes Produktionsprofil')}

                                                                      ${escapeHtml(profileDimensionLabel(profile))}
                                                                      + ${profile.is_builtin ? 'Standard' : `
                                                                      `} +
                                                                      `; + }).join(''); + container.querySelectorAll('[data-image-factory-profile]').forEach(input => input.addEventListener('change', () => { + const id = Number(input.dataset.imageFactoryProfile); + if (input.checked) state.imageFactorySelectedProfiles.add(id); else state.imageFactorySelectedProfiles.delete(id); + input.closest('.image-factory-profile-card')?.classList.toggle('is-selected', input.checked); + updateImageFactorySelection(); + })); + container.querySelectorAll('[data-image-factory-profile-edit]').forEach(button => button.addEventListener('click', () => openImageFactoryProfileModal(Number(button.dataset.imageFactoryProfileEdit)))); + container.querySelectorAll('[data-image-factory-profile-delete]').forEach(button => button.addEventListener('click', async () => { + const id = Number(button.dataset.imageFactoryProfileDelete); + if (!confirm('Dieses eigene Produktionsprofil löschen? Bestehende Aufträge und Bilder bleiben erhalten.')) return; + try { await api('DELETE', `/api/image-batch/profiles/${id}`); state.imageFactorySelectedProfiles.delete(id); await loadImageFactoryProfiles(); toast('Produktionsprofil gelöscht.'); } + catch (error) { toast(`Profil konnte nicht gelöscht werden: ${error.message}`); } + })); +} + +async function loadImageFactorySources({ silent = false } = {}) { + const container = document.getElementById('image-factory-source-grid'); + if (!silent && container) container.innerHTML = '
                                                                      Bilder werden geladen …
                                                                      '; + try { + const params = new URLSearchParams({ page: '1', page_size: '96', source: document.getElementById('image-factory-source-filter')?.value || 'all', sort: 'newest' }); + const search = document.getElementById('image-factory-source-search')?.value.trim(); + if (search) params.set('q', search); + const result = await api('GET', `/api/media-library?${params}`); + state.imageFactorySources = result.items || []; + renderImageFactorySources(); + } catch (error) { + if (container) container.innerHTML = `
                                                                      Bilder konnten nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +async function loadImageFactoryProfiles() { + try { + const result = await api('GET', '/api/image-batch/profiles'); + state.imageFactoryProfiles = result.items || []; + if (!state.imageFactorySelectedProfiles.size && state.imageFactoryProfiles.length) state.imageFactorySelectedProfiles.add(Number(state.imageFactoryProfiles[0].id)); + state.imageFactorySelectedProfiles = new Set([...state.imageFactorySelectedProfiles].filter(id => state.imageFactoryProfiles.some(profile => Number(profile.id) === Number(id)))); + renderImageFactoryProfiles(); + updateImageFactorySelection(); + } catch (error) { + const container = document.getElementById('image-factory-profile-grid'); + if (container) container.innerHTML = `
                                                                      Profile konnten nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +function renderImageFactorySummary() { + const summary = state.imageFactorySummary || {}; + document.querySelectorAll('[data-batch-summary]').forEach(node => { node.textContent = String(summary[node.dataset.batchSummary] || 0); }); +} + +function batchItemStatusLabel(status) { + return ({ queued: 'Wartend', running: 'Wird erzeugt', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' })[status] || status; +} + +function renderImageFactoryJobItems(job) { + if (!job.items) return '
                                                                      Details werden geladen …
                                                                      '; + return `
                                                                      ${job.items.map(item => `
                                                                      + +
                                                                      ${escapeHtml(item.profile_name)}${escapeHtml(item.source_path.split('/').pop())}${item.error_message ? `${escapeHtml(item.error_message)}` : ''}
                                                                      + ${escapeHtml(batchItemStatusLabel(item.status))} + ${['failed','cancelled'].includes(item.status) ? `` : ''} + ${item.output_path ? `Download` : ''} +
                                                                      `).join('')}
                                                                      `; +} + +function renderImageFactoryJobs() { + const container = document.getElementById('image-factory-jobs'); + if (!container) return; + if (!state.imageFactoryJobs.length) { + container.innerHTML = '
                                                                      Noch keine Produktionsaufträge. Wähle Bilder und Profile und starte die erste Produktion.
                                                                      '; + } else { + container.innerHTML = state.imageFactoryJobs.map(job => { + const [label, tone] = imageFactoryStatusMeta(job.status); + const terminal = ['completed','partial','failed','cancelled'].includes(job.status); + const expanded = state.imageFactoryExpanded.has(job.id); + const done = Number(job.completed_items || 0) + Number(job.failed_items || 0) + Number(job.cancelled_items || 0); + const total = Math.max(1, Number(job.total_items || 0)); + const percent = Math.min(100, Math.round(done / total * 100)); + return `
                                                                      + +
                                                                      +
                                                                      + ${['queued','running'].includes(job.status) ? `` : ''} + ${job.status === 'paused' ? `` : ''} + ${['failed','partial','cancelled'].includes(job.status) ? `` : ''} + ${Number(job.completed_items || 0) > 0 ? `Ergebnisse als ZIP` : ''} + ${terminal ? `` : ''} +
                                                                      +
                                                                      ${expanded ? renderImageFactoryJobItems(job) : ''}
                                                                      +
                                                                      `; + }).join(''); + } + container.querySelectorAll('[data-image-factory-job-toggle]').forEach(button => button.addEventListener('click', async () => { + const id = button.dataset.imageFactoryJobToggle; + if (state.imageFactoryExpanded.has(id)) state.imageFactoryExpanded.delete(id); else state.imageFactoryExpanded.add(id); + const index = state.imageFactoryJobs.findIndex(job => job.id === id); + if (index >= 0 && state.imageFactoryExpanded.has(id) && !state.imageFactoryJobs[index].items) { + try { state.imageFactoryJobs[index] = await api('GET', `/api/image-batch/jobs/${encodeURIComponent(id)}`); } catch (error) { toast(error.message); } + } + renderImageFactoryJobs(); + })); + container.querySelectorAll('[data-image-factory-action]').forEach(button => button.addEventListener('click', () => handleImageFactoryJobAction(button.dataset.imageFactoryAction, button.dataset.jobId))); + container.querySelectorAll('[data-image-factory-retry-item]').forEach(button => button.addEventListener('click', async () => { + try { await api('POST', `/api/image-batch/jobs/${encodeURIComponent(button.dataset.jobId)}/items/${encodeURIComponent(button.dataset.imageFactoryRetryItem)}/retry`); toast('Bildposition wurde erneut eingereiht.'); await loadImageFactoryJobs({ silent: true }); } + catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); } + })); + const pagination = state.imageFactoryJobPagination || {}; + document.getElementById('image-factory-job-page-info')?.replaceChildren(document.createTextNode(`Seite ${pagination.page || 1} von ${pagination.total_pages || 1}`)); + const prev = document.getElementById('image-factory-job-prev'); const next = document.getElementById('image-factory-job-next'); + if (prev) prev.disabled = Number(pagination.page || 1) <= 1; + if (next) next.disabled = Number(pagination.page || 1) >= Number(pagination.total_pages || 1); +} + +async function loadImageFactoryJobs({ silent = false } = {}) { + const container = document.getElementById('image-factory-jobs'); + if (!silent && container && !state.imageFactoryJobs.length) container.innerHTML = '
                                                                      Produktionsaufträge werden geladen …
                                                                      '; + try { + const result = await api('GET', `/api/image-batch/jobs?page=${state.imageFactoryJobPage}&page_size=${state.imageFactoryJobPageSize}`); + const previous = new Map(state.imageFactoryJobs.map(job => [job.id, job])); + state.imageFactoryJobs = (result.items || []).map(job => state.imageFactoryExpanded.has(job.id) && previous.get(job.id)?.items ? { ...job, items: previous.get(job.id).items } : job); + const expandedJobs = state.imageFactoryJobs.filter(job => state.imageFactoryExpanded.has(job.id)); + if (expandedJobs.length) { + const details = await Promise.all(expandedJobs.map(job => api('GET', `/api/image-batch/jobs/${encodeURIComponent(job.id)}`).catch(() => null))); + const detailMap = new Map(details.filter(Boolean).map(job => [job.id, job])); + state.imageFactoryJobs = state.imageFactoryJobs.map(job => detailMap.get(job.id) || job); + } + state.imageFactoryJobPagination = result.pagination || { page: 1, total_pages: 1, total: 0 }; + state.imageFactoryJobPage = Number(state.imageFactoryJobPagination.page || 1); + state.imageFactorySummary = result.summary || null; + renderImageFactorySummary(); + renderImageFactoryJobs(); + const active = state.imageFactoryJobs.some(job => ['queued','running'].includes(job.status)); + if (imageFactoryPollTimer) clearTimeout(imageFactoryPollTimer); + if (active && document.getElementById('image-factory')?.classList.contains('active')) imageFactoryPollTimer = setTimeout(() => loadImageFactoryJobs({ silent: true }), 1600); + } catch (error) { + if (container) container.innerHTML = `
                                                                      Aufträge konnten nicht geladen werden: ${escapeHtml(error.message)}
                                                                      `; + } +} + +async function loadImageFactory() { + await Promise.all([loadImageFactoryProfiles(), loadImageFactorySources(), loadImageFactoryJobs()]); +} + +async function submitImageFactoryJob() { + const button = document.getElementById('image-factory-submit'); + const sourcePaths = [...state.imageFactorySelectedSources]; + const profileIds = [...state.imageFactorySelectedProfiles]; + if (!sourcePaths.length || !profileIds.length) return toast('Bitte mindestens ein Bild und ein Produktionsprofil auswählen.'); + const original = button?.textContent || 'Bildproduktion starten'; + if (button) { button.disabled = true; button.textContent = 'Auftrag wird angelegt …'; } + const watermarkEnabled = document.getElementById('image-factory-watermark')?.checked; + const overrides = { + remove_background: document.getElementById('image-factory-remove-bg')?.checked || false, + background_sensitivity: Number(document.getElementById('image-factory-bg-sensitivity')?.value || 45), + watermark: watermarkEnabled ? { + enabled: true, + text: document.getElementById('image-factory-watermark-text')?.value || 'Vendoo', + position: document.getElementById('image-factory-watermark-position')?.value || 'bottom-right', + opacity: Number(document.getElementById('image-factory-watermark-opacity')?.value || 40), + } : { enabled: false }, + }; + try { + const job = await api('POST', '/api/image-batch/jobs', { name: document.getElementById('image-factory-name')?.value.trim(), source_paths: sourcePaths, profile_ids: profileIds, overrides }); + toast(`${job.total_items} Bildausgaben wurden in die Produktion eingereiht.`); + state.imageFactoryJobPage = 1; + state.imageFactoryExpanded.add(job.id); + await loadImageFactoryJobs({ silent: true }); + document.getElementById('image-factory-jobs')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch (error) { toast(`Bildproduktion konnte nicht gestartet werden: ${error.message}`); } + finally { if (button) { button.disabled = false; button.textContent = original; } updateImageFactorySelection(); } +} + +async function handleImageFactoryJobAction(action, jobId) { + if (action === 'delete' && !confirm('Diesen abgeschlossenen Auftrag aus dem Job-Center löschen? Die erzeugten Bilder bleiben erhalten.')) return; + try { + if (action === 'delete') await api('DELETE', `/api/image-batch/jobs/${encodeURIComponent(jobId)}`); + else await api('POST', `/api/image-batch/jobs/${encodeURIComponent(jobId)}/${action}`); + toast(({ pause: 'Auftrag wird pausiert.', resume: 'Auftrag wird fortgesetzt.', cancel: 'Auftrag wird abgebrochen.', retry: 'Fehler wurden erneut eingereiht.', delete: 'Auftrag gelöscht.' })[action] || 'Auftrag aktualisiert.'); + await loadImageFactoryJobs({ silent: true }); + } catch (error) { toast(`Aktion fehlgeschlagen: ${error.message}`); } +} + +function openImageFactoryProfileModal(profileId = null) { + imageFactoryEditingProfileId = profileId; + const profile = state.imageFactoryProfiles.find(item => Number(item.id) === Number(profileId)); + const operations = profile?.operations || {}; + const resize = operations.resize || {}; + document.getElementById('image-factory-profile-title').textContent = profile ? 'Produktionsprofil bearbeiten' : 'Produktionsprofil anlegen'; + document.getElementById('image-factory-profile-name').value = profile?.name || ''; + document.getElementById('image-factory-profile-description').value = profile?.description || ''; + document.getElementById('image-factory-profile-width').value = resize.width || 1600; + document.getElementById('image-factory-profile-height').value = resize.height || 1600; + document.getElementById('image-factory-profile-fit').value = resize.fit || 'contain'; + document.getElementById('image-factory-profile-background').value = resize.background || '#ffffff'; + document.getElementById('image-factory-profile-format').value = operations.format || 'jpeg'; + document.getElementById('image-factory-profile-quality').value = operations.quality || 90; + document.getElementById('image-factory-profile-pattern').value = profile?.filename_pattern || '{name}-{profile}-{index}'; + const modal = document.getElementById('image-factory-profile-modal'); + modal?.classList.remove('hidden'); modal?.setAttribute('aria-hidden', 'false'); +} + +function closeImageFactoryProfileModal() { + const modal = document.getElementById('image-factory-profile-modal'); + modal?.classList.add('hidden'); modal?.setAttribute('aria-hidden', 'true'); imageFactoryEditingProfileId = null; +} + +async function saveImageFactoryProfile() { + const data = { + name: document.getElementById('image-factory-profile-name')?.value.trim(), + description: document.getElementById('image-factory-profile-description')?.value.trim(), + filename_pattern: document.getElementById('image-factory-profile-pattern')?.value.trim(), + operations: { + resize: { + width: Number(document.getElementById('image-factory-profile-width')?.value || 0), + height: Number(document.getElementById('image-factory-profile-height')?.value || 0), + fit: document.getElementById('image-factory-profile-fit')?.value || 'contain', + background: document.getElementById('image-factory-profile-background')?.value || '#ffffff', + }, + format: document.getElementById('image-factory-profile-format')?.value || 'jpeg', + quality: Number(document.getElementById('image-factory-profile-quality')?.value || 90), + sharpness: 12, + }, + }; + if (!data.name) return toast('Bitte einen Namen für das Produktionsprofil eingeben.'); + const button = document.getElementById('image-factory-profile-save'); + if (button) button.disabled = true; + try { + const profile = imageFactoryEditingProfileId + ? await api('PATCH', `/api/image-batch/profiles/${imageFactoryEditingProfileId}`, data) + : await api('POST', '/api/image-batch/profiles', data); + state.imageFactorySelectedProfiles.add(Number(profile.id)); + closeImageFactoryProfileModal(); await loadImageFactoryProfiles(); toast('Produktionsprofil gespeichert.'); + } catch (error) { toast(`Profil konnte nicht gespeichert werden: ${error.message}`); } + finally { if (button) button.disabled = false; } +} + +function setupImageFactory() { + document.getElementById('image-factory-refresh')?.addEventListener('click', loadImageFactory); + document.getElementById('image-factory-jobs-refresh')?.addEventListener('click', () => loadImageFactoryJobs()); + document.getElementById('image-factory-start')?.addEventListener('click', () => document.getElementById('image-factory-source-grid')?.scrollIntoView({ behavior: 'smooth', block: 'center' })); + document.getElementById('image-factory-submit')?.addEventListener('click', submitImageFactoryJob); + document.getElementById('image-factory-source-filter')?.addEventListener('change', () => loadImageFactorySources()); + document.getElementById('image-factory-source-search')?.addEventListener('input', debounce(() => loadImageFactorySources({ silent: true }), 350)); + document.getElementById('image-factory-select-visible')?.addEventListener('click', () => { + const visible = state.imageFactorySources.map(item => item.image_path); + const allSelected = visible.length && visible.every(path => state.imageFactorySelectedSources.has(path)); + visible.forEach(path => allSelected ? state.imageFactorySelectedSources.delete(path) : state.imageFactorySelectedSources.add(path)); + renderImageFactorySources(); updateImageFactorySelection(); + }); + document.getElementById('image-factory-clear-sources')?.addEventListener('click', () => { state.imageFactorySelectedSources.clear(); renderImageFactorySources(); updateImageFactorySelection(); }); + document.getElementById('image-factory-bg-sensitivity')?.addEventListener('input', event => { document.getElementById('image-factory-bg-value').textContent = event.target.value; }); + document.getElementById('image-factory-watermark-opacity')?.addEventListener('input', event => { document.getElementById('image-factory-watermark-value').textContent = `${event.target.value} %`; }); + document.getElementById('image-factory-new-profile')?.addEventListener('click', () => openImageFactoryProfileModal()); + document.getElementById('image-factory-profile-close')?.addEventListener('click', closeImageFactoryProfileModal); + document.getElementById('image-factory-profile-cancel')?.addEventListener('click', closeImageFactoryProfileModal); + document.getElementById('image-factory-profile-save')?.addEventListener('click', saveImageFactoryProfile); + document.getElementById('image-factory-profile-modal')?.addEventListener('click', event => { if (event.target.id === 'image-factory-profile-modal') closeImageFactoryProfileModal(); }); + document.getElementById('image-factory-job-page-size')?.addEventListener('change', event => { state.imageFactoryJobPageSize = Number(event.target.value || 10); state.imageFactoryJobPage = 1; loadImageFactoryJobs(); }); + document.getElementById('image-factory-job-prev')?.addEventListener('click', () => { if (state.imageFactoryJobPage > 1) { state.imageFactoryJobPage -= 1; loadImageFactoryJobs(); } }); + document.getElementById('image-factory-job-next')?.addEventListener('click', () => { if (state.imageFactoryJobPage < Number(state.imageFactoryJobPagination.total_pages || 1)) { state.imageFactoryJobPage += 1; loadImageFactoryJobs(); } }); + document.getElementById('media-send-to-factory')?.addEventListener('click', () => { + for (const imagePath of state.mediaSelected) state.imageFactorySelectedSources.add(imagePath); + switchToTab('image-factory'); + toast(`${state.mediaSelected.size} Galeriebild(er) für die Bildproduktion übernommen.`); + }); +} + + +// --- Article Quality Center --- +function qualityTone(report) { + if (report.blockers > 0) return 'blocked'; + if (report.ready) return 'ready'; + return 'review'; +} + +function qualityStatusLabel(report) { + if (report.blockers > 0) return `${report.blockers} Blocker`; + if (report.ready) return 'Bereit'; + return 'Prüfen'; +} + +function qualityIssueIcon(severity) { + const names = { error: 'x', warning: 'triangle-alert', recommendation: 'sparkles' }; + return window.VendooIcons?.svg(names[severity] || 'circle-help', { size: 18, strokeWidth: 2 }) || (severity === 'error' ? '!' : '·'); +} + +function renderQualityList() { + const container = document.getElementById('quality-list'); + if (!container) return; + if (!state.qualityItems.length) { + container.innerHTML = '

                                                                      Keine Artikel entsprechen den gewählten Filtern.

                                                                      '; + return; + } + container.innerHTML = state.qualityItems.map(report => { + const item = report.listing || {}; + const active = Number(state.qualityCurrent?.listing_id) === Number(report.listing_id); + const tone = qualityTone(report); + return ``; + }).join(''); + container.querySelectorAll('[data-quality-listing-id]').forEach(button => button.addEventListener('click', () => loadQualityDetail(button.dataset.qualityListingId))); +} + +function renderQualitySummary() { + const summary = state.qualitySummary || {}; + const values = { + 'quality-summary-average': summary.average_score ?? 0, + 'quality-summary-ready': summary.ready ?? 0, + 'quality-summary-blocked': summary.blocked ?? 0, + 'quality-summary-total': summary.total ?? 0, + }; + Object.entries(values).forEach(([id, value]) => { const el = document.getElementById(id); if (el) el.textContent = value; }); +} + +function renderQualityPagination() { + const pagination = state.qualityPagination || {}; + const page = Number(pagination.page || 1); const totalPages = Number(pagination.total_pages || 1); const total = Number(pagination.total || 0); + const start = total ? (page - 1) * Number(pagination.page_size || state.qualityPageSize) + 1 : 0; + const end = Math.min(total, page * Number(pagination.page_size || state.qualityPageSize)); + const info = document.getElementById('quality-page-info'); if (info) info.textContent = `Seite ${page} von ${totalPages}`; + const range = document.getElementById('quality-list-range'); if (range) range.textContent = total ? `${start}–${end} von ${total}` : '0 Artikel'; + const prev = document.getElementById('quality-prev'); const next = document.getElementById('quality-next'); + if (prev) prev.disabled = page <= 1; if (next) next.disabled = page >= totalPages; +} + +async function loadQualityCenter({ silent = false } = {}) { + const container = document.getElementById('quality-list'); + if (!silent && container) container.innerHTML = '

                                                                      Artikelqualität wird geladen …

                                                                      '; + const params = new URLSearchParams({ + q: document.getElementById('quality-search')?.value.trim() || '', + status: document.getElementById('quality-status')?.value || 'all', + grade: document.getElementById('quality-grade')?.value || 'all', + page: String(state.qualityPage), + page_size: String(state.qualityPageSize), + }); + try { + const result = await api('GET', `/api/quality-center?${params}`); + state.qualityItems = result.items || []; + state.qualityPagination = result.pagination || { page: 1, total_pages: 1, total: 0 }; + state.qualitySummary = result.summary || {}; + renderQualitySummary(); renderQualityPagination(); renderQualityList(); + if (!state.qualityCurrent && state.qualityItems[0]) loadQualityDetail(state.qualityItems[0].listing_id, { quiet: true }); + } catch (error) { + if (container) container.innerHTML = `

                                                                      Quality Center konnte nicht geladen werden: ${escapeHtml(error.message)}

                                                                      `; + } +} + +function renderQualityIssues(report) { + const container = document.getElementById('quality-issues'); + if (!container) return; + const filter = state.qualityIssueFilter; + const issues = (report.issues || []).filter(item => filter === 'all' || item.severity === filter); + if (!issues.length) { + container.innerHTML = '
                                                                      Keine Punkte in diesem BereichDer Artikel erfüllt die aktuell geprüften Qualitätsregeln.
                                                                      '; + return; + } + container.innerHTML = issues.map(item => `
                                                                      + ${qualityIssueIcon(item.severity)} +
                                                                      ${escapeHtml(item.title)}

                                                                      ${escapeHtml(item.detail || '')}

                                                                      ${escapeHtml(item.category || '')}${item.field ? ` · Feld: ${escapeHtml(item.field)}` : ''}
                                                                      + −${Number(item.points || 0)} +
                                                                      `).join(''); +} + +function renderQualityDetail(report) { + state.qualityCurrent = report; + document.getElementById('quality-detail-empty')?.classList.add('hidden'); + document.getElementById('quality-detail-content')?.classList.remove('hidden'); + const title = document.getElementById('quality-detail-title'); if (title) title.textContent = report.listing?.title || 'Unbenannter Artikel'; + const platform = document.getElementById('quality-detail-platform'); if (platform) platform.textContent = `${report.platform?.name || platformLabel(report.listing?.platform)} · Note ${report.grade}`; + const meta = document.getElementById('quality-detail-meta'); if (meta) meta.textContent = `${report.listing?.sku || 'Ohne SKU'} · ${report.facts?.photo_count || 0} Bilder · ${report.facts?.title_length || 0} Titelzeichen`; + const score = document.getElementById('quality-detail-score'); if (score) score.textContent = report.score; + const ring = document.getElementById('quality-score-ring'); if (ring) { ring.dataset.grade = report.grade || 'E'; ring.style.setProperty('--quality-score', `${report.score * 3.6}deg`); } + const readiness = document.getElementById('quality-readiness'); + if (readiness) readiness.innerHTML = `${report.ready ? 'Bereit zur Veröffentlichung' : report.blockers ? 'Veröffentlichung blockiert' : 'Überarbeitung empfohlen'}${report.blockers || 0} Blocker · ${report.warnings || 0} Warnungen · ${report.recommendations || 0} Empfehlungen`; + const finance = report.financials || {}; + const financials = document.getElementById('quality-financials'); + if (financials) financials.innerHTML = `
                                                                      Preis${formatCurrency(finance.price || report.listing?.price || 0)}
                                                                      Gebühren${formatCurrency(finance.fees || 0)}
                                                                      Geschätzter Erlös${formatCurrency(finance.estimated_net || 0)}
                                                                      `; + renderQualityIssues(report); + const duplicates = document.getElementById('quality-duplicates'); + if (duplicates) duplicates.innerHTML = report.duplicates?.length ? report.duplicates.map(item => ``).join('') : '

                                                                      Keine auffälligen Duplikate gefunden.

                                                                      '; + duplicates?.querySelectorAll('[data-quality-duplicate-id]').forEach(button => button.addEventListener('click', () => showDetail(button.dataset.qualityDuplicateId))); + const images = document.getElementById('quality-images'); + if (images) images.innerHTML = report.images?.length ? report.images.map(item => `
                                                                      ${item.missing || item.unreadable ? '!' : '✓'}
                                                                      ${escapeHtml(item.path || 'Bild')}${item.width ? `${item.width} × ${item.height} px · ${item.megapixels} MP · ${escapeHtml(item.format || '')}` : item.missing ? 'Datei fehlt' : 'Nicht lesbar'}
                                                                      `).join('') : '

                                                                      Für eine vollständige Bildprüfung bitte „Neu analysieren“ verwenden.

                                                                      '; + const history = document.getElementById('quality-history'); + if (history) history.innerHTML = report.history?.length ? report.history.map(item => `
                                                                      ${new Date(String(item.analyzed_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE')}${item.score}Note ${item.grade}
                                                                      `).join('') : '

                                                                      Noch kein gespeicherter Verlauf.

                                                                      '; + const delta = document.getElementById('quality-score-delta'); + if (delta) { const value = Number(report.comparison?.delta || 0); delta.textContent = value ? `${value > 0 ? '+' : ''}${value} seit letzter Prüfung` : 'Noch kein Vergleich'; delta.dataset.tone = value > 0 ? 'up' : value < 0 ? 'down' : 'neutral'; } + renderQualityList(); +} + +async function loadQualityDetail(listingId, { quiet = false } = {}) { + const panel = document.getElementById('quality-detail-content'); + if (!quiet && panel) panel.classList.add('is-loading'); + try { + const report = await api('GET', `/api/quality-center/${listingId}`, null, { timeoutMs: 20000 }); + renderQualityDetail(report); + } catch (error) { toast(`Qualitätsprüfung fehlgeschlagen: ${error.message}`); } + finally { panel?.classList.remove('is-loading'); } +} + +async function runQualityAnalysis(listingId = state.qualityCurrent?.listing_id) { + if (!listingId) return; + const button = document.getElementById('quality-run-analysis'); const original = button?.textContent; + if (button) { button.disabled = true; button.textContent = 'Analyse läuft …'; } + try { + const report = await api('POST', `/api/quality-center/${listingId}/analyze`, {}, { timeoutMs: 30000 }); + renderQualityDetail(report); await loadQualityCenter({ silent: true }); toast(`Qualitätsanalyse abgeschlossen: ${report.score}/100`); + } catch (error) { toast(`Analyse fehlgeschlagen: ${error.message}`); } + finally { if (button) { button.disabled = false; button.textContent = original || 'Neu analysieren'; } } +} + +async function analyzeVisibleQualityItems() { + const ids = state.qualityItems.map(item => item.listing_id).slice(0, 25); + if (!ids.length) return toast('Keine sichtbaren Artikel vorhanden.'); + const button = document.getElementById('quality-analyze-visible'); const original = button?.textContent; + if (button) button.disabled = true; + let done = 0; let failed = 0; + for (const id of ids) { + if (button) button.textContent = `${done + 1} / ${ids.length} prüfen`; + try { await api('POST', `/api/quality-center/${id}/analyze`, {}, { timeoutMs: 30000 }); done += 1; } catch { failed += 1; } + } + if (button) { button.disabled = false; button.textContent = original || 'Sichtbare prüfen'; } + await loadQualityCenter({ silent: true }); + if (state.qualityCurrent?.listing_id) await loadQualityDetail(state.qualityCurrent.listing_id, { quiet: true }); + toast(`${done} Artikel geprüft${failed ? ` · ${failed} fehlgeschlagen` : ''}.`); +} + +function openQualityAiModal() { + if (!state.qualityCurrent?.listing_id) return; + state.qualityAiDraft = null; + const modal = document.getElementById('quality-ai-modal'); modal?.classList.remove('hidden'); + document.getElementById('quality-ai-compare')?.classList.add('hidden'); + document.getElementById('quality-ai-apply')?.classList.add('hidden'); + const status = document.getElementById('quality-ai-status'); if (status) status.textContent = 'Wähle die Felder und erstelle einen Vorschlag.'; + const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = ''; +} + +function closeQualityAiModal() { document.getElementById('quality-ai-modal')?.classList.add('hidden'); state.qualityAiDraft = null; } + +async function generateQualityAiSuggestion() { + const fields = [['title','quality-ai-field-title'],['description','quality-ai-field-description'],['tags','quality-ai-field-tags']].filter(([, id]) => document.getElementById(id)?.checked).map(([field]) => field); + if (!fields.length) return toast('Bitte mindestens ein Feld auswählen.'); + const button = document.getElementById('quality-ai-generate'); const status = document.getElementById('quality-ai-status'); + if (button) button.disabled = true; if (status) status.textContent = 'Der aktive AI-Provider erstellt sichere Vorschläge …'; + try { + const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/ai-suggestions`, { fields, mode: document.getElementById('quality-ai-mode')?.value || 'balanced' }, { timeoutMs: 60000 }); + state.qualityAiDraft = result; + const original = result.original || {}; const suggestions = result.suggestions || {}; + document.getElementById('quality-ai-original-title').value = original.title || ''; + document.getElementById('quality-ai-original-description').value = original.description || ''; + document.getElementById('quality-ai-original-tags').value = (original.tags || []).join(', '); + document.getElementById('quality-ai-suggested-title').value = suggestions.title ?? original.title ?? ''; + document.getElementById('quality-ai-suggested-description').value = suggestions.description ?? original.description ?? ''; + document.getElementById('quality-ai-suggested-tags').value = (suggestions.tags ?? original.tags ?? []).join(', '); + document.getElementById('quality-ai-compare')?.classList.remove('hidden'); document.getElementById('quality-ai-apply')?.classList.remove('hidden'); + const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = (suggestions.reasons || []).map(reason => `${escapeHtml(reason)}`).join(''); + if (status) status.textContent = `Vorschlag über ${result.provider} erstellt. Prüfe und bearbeite ihn vor dem Übernehmen.`; + } catch (error) { if (status) status.textContent = `AI-Vorschlag fehlgeschlagen: ${error.message}`; } + finally { if (button) button.disabled = false; } +} + +async function applyQualityAiSuggestion() { + if (!state.qualityAiDraft || !state.qualityCurrent?.listing_id) return; + const payload = {}; + if (document.getElementById('quality-ai-field-title')?.checked) payload.title = document.getElementById('quality-ai-suggested-title').value.trim(); + if (document.getElementById('quality-ai-field-description')?.checked) payload.description = document.getElementById('quality-ai-suggested-description').value.trim(); + if (document.getElementById('quality-ai-field-tags')?.checked) payload.tags = document.getElementById('quality-ai-suggested-tags').value.split(',').map(value => value.trim()).filter(Boolean); + const button = document.getElementById('quality-ai-apply'); if (button) button.disabled = true; + try { + const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/apply-suggestions`, payload, { timeoutMs: 30000 }); + closeQualityAiModal(); renderQualityDetail(result.report); await loadQualityCenter({ silent: true }); toast('Bestätigte Verbesserungen wurden gespeichert.'); + } catch (error) { toast(`Verbesserungen konnten nicht gespeichert werden: ${error.message}`); } + finally { if (button) button.disabled = false; } +} + +function setupQualityCenter() { + document.getElementById('quality-refresh')?.addEventListener('click', () => loadQualityCenter()); + document.getElementById('quality-analyze-visible')?.addEventListener('click', analyzeVisibleQualityItems); + document.getElementById('quality-search')?.addEventListener('input', debounce(() => { state.qualityPage = 1; loadQualityCenter({ silent: true }); }, 300)); + document.getElementById('quality-status')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-grade')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-page-size')?.addEventListener('change', event => { state.qualityPageSize = Number(event.target.value || 10); state.qualityPage = 1; loadQualityCenter(); }); + document.getElementById('quality-prev')?.addEventListener('click', () => { if (state.qualityPage > 1) { state.qualityPage -= 1; loadQualityCenter(); } }); + document.getElementById('quality-next')?.addEventListener('click', () => { if (state.qualityPage < Number(state.qualityPagination.total_pages || 1)) { state.qualityPage += 1; loadQualityCenter(); } }); + document.getElementById('quality-run-analysis')?.addEventListener('click', () => runQualityAnalysis()); + document.getElementById('quality-open-article')?.addEventListener('click', () => state.qualityCurrent?.listing_id && showDetail(state.qualityCurrent.listing_id)); + document.getElementById('quality-open-ai')?.addEventListener('click', openQualityAiModal); + document.querySelectorAll('[data-quality-issue-filter]').forEach(button => button.addEventListener('click', () => { + state.qualityIssueFilter = button.dataset.qualityIssueFilter || 'all'; + document.querySelectorAll('[data-quality-issue-filter]').forEach(item => item.classList.toggle('active', item === button)); + if (state.qualityCurrent) renderQualityIssues(state.qualityCurrent); + })); + document.getElementById('quality-ai-close')?.addEventListener('click', closeQualityAiModal); + document.getElementById('quality-ai-cancel')?.addEventListener('click', closeQualityAiModal); + document.getElementById('quality-ai-generate')?.addEventListener('click', generateQualityAiSuggestion); + document.getElementById('quality-ai-apply')?.addEventListener('click', applyQualityAiSuggestion); + document.getElementById('quality-ai-modal')?.addEventListener('click', event => { if (event.target.id === 'quality-ai-modal') closeQualityAiModal(); }); +} + +// --- System Diagnostics --- +function diagnosticTone(status) { + return status === 'ok' ? 'ok' : status === 'error' ? 'error' : status === 'warning' ? 'warning' : 'optional'; +} + +function diagnosticIcon(status) { + const name = status === 'ok' ? 'shield-check' : status === 'error' ? 'x' : 'circle-help'; + return window.VendooIcons?.svg(name, { size: 18, strokeWidth: 2 }) || ''; +} + +function renderSystemDiagnostics(report) { + const summary = document.getElementById('system-diagnostics-summary'); + const grid = document.getElementById('system-diagnostics-grid'); + const copy = document.getElementById('system-diagnostics-copy'); + if (!summary || !grid) return; + const errors = report.checks?.filter(item => item.status === 'error').length || 0; + const warnings = report.checks?.filter(item => ['warning', 'optional'].includes(item.status)).length || 0; + summary.className = `diagnostics-summary ${errors ? 'is-error' : warnings ? 'is-warning' : 'is-ok'}`; + summary.innerHTML = `${errors ? `${errors} kritische Prüfung(en) fehlgeschlagen` : 'Vendoo-Kernsystem ist betriebsbereit'}${warnings ? `${warnings} Hinweis(e) · ` : ''}${report.checks?.length || 0} Prüfungen in ${report.duration_ms || 0} ms · Build ${escapeHtml(report.version || VENDOO_UI_BUILD)}`; + grid.innerHTML = (report.checks || []).map(check => ` +
                                                                      + ${diagnosticIcon(check.status)} +
                                                                      ${escapeHtml(check.label)}

                                                                      ${escapeHtml(check.detail || '')}

                                                                      + ${check.status === 'ok' ? 'OK' : check.status === 'error' ? 'Fehler' : check.status === 'warning' ? 'Hinweis' : 'Optional'} +
                                                                      `).join(''); + if (copy) copy.disabled = false; +} + +async function runSystemDiagnostics() { + const button = document.getElementById('system-diagnostics-btn'); + const summary = document.getElementById('system-diagnostics-summary'); + const grid = document.getElementById('system-diagnostics-grid'); + const original = button?.textContent || 'Systemprüfung starten'; + if (button) { button.disabled = true; button.textContent = 'Prüfung läuft …'; } + if (summary) { summary.className = 'diagnostics-summary is-running'; summary.textContent = 'Datenbank, Dateisystem, UI, Extensions und optionale Dienste werden geprüft …'; } + if (grid) grid.innerHTML = '
                                                                      '; + try { + const report = await api('GET', '/api/admin/diagnostics', null, { retry: false, timeoutMs: 15000 }); + clientDiagnostics.lastSystemReport = report; + renderSystemDiagnostics(report); + toast(report.ok ? 'Systemprüfung erfolgreich.' : 'Systemprüfung hat kritische Fehler gefunden.'); + } catch (error) { + if (summary) { summary.className = 'diagnostics-summary is-error'; summary.textContent = `Systemprüfung fehlgeschlagen: ${error.message}`; } + if (grid) grid.innerHTML = ''; + toast(`Systemprüfung fehlgeschlagen: ${error.message}`); + } finally { + if (button) { button.disabled = false; button.textContent = original; } + } +} + +async function copySystemDiagnostics() { + const report = clientDiagnostics.lastSystemReport; + if (!report) return toast('Bitte zuerst eine Systemprüfung starten.'); + const text = [ + `Vendoo Diagnose ${report.version || VENDOO_UI_BUILD}`, + `Erstellt: ${report.generated_at || new Date().toISOString()}`, + `Runtime: ${report.runtime?.node || '?'} · ${report.runtime?.platform || '?'} ${report.runtime?.arch || ''}`, + '', + ...(report.checks || []).map(check => `[${String(check.status).toUpperCase()}] ${check.label}: ${check.detail}`), + '', + `Client-Fehler (letzte ${clientDiagnostics.incidents.length}):`, + ...clientDiagnostics.incidents.slice(0, 10).map(item => `${item.at} · ${item.type} · ${item.view} · ${item.message} · ${item.requestId || ''}`), + ].join('\n'); + try { + await navigator.clipboard.writeText(text); + toast('Diagnose in die Zwischenablage kopiert.'); + } catch { + const area = document.createElement('textarea'); + area.value = text; document.body.append(area); area.select(); document.execCommand('copy'); area.remove(); + toast('Diagnose kopiert.'); + } +} + +// --- System Info --- +async function loadSystemInfo() { + try { + const sys = await api('GET', '/api/admin/system'); + document.getElementById('system-info-cards').innerHTML = ` +
                                                                      +
                                                                      Server Uptime
                                                                      +
                                                                      ${sys.uptime}
                                                                      +
                                                                      +
                                                                      +
                                                                      Benutzer
                                                                      +
                                                                      ${sys.total_users}
                                                                      +
                                                                      +
                                                                      +
                                                                      Artikel
                                                                      +
                                                                      ${sys.total_listings}
                                                                      +
                                                                      +
                                                                      +
                                                                      Aktive Sessions
                                                                      +
                                                                      ${sys.active_sessions}
                                                                      +
                                                                      +
                                                                      +
                                                                      Datenbankgröße
                                                                      +
                                                                      ${sys.db_size}
                                                                      +
                                                                      +
                                                                      +
                                                                      Node.js
                                                                      +
                                                                      ${sys.node_version}
                                                                      +
                                                                      +
                                                                      +
                                                                      Speicher (RAM)
                                                                      +
                                                                      ${sys.memory_mb} MB
                                                                      +
                                                                      +
                                                                      +
                                                                      Plattform
                                                                      +
                                                                      ${sys.platform}
                                                                      +
                                                                      + `; + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function loadMySessions() { + try { + const sessions = await api('GET', '/api/my/sessions'); + const container = document.getElementById('my-sessions-list'); + container.innerHTML = sessions.length ? sessions.map(s => { + const ua = parseUA(s.user_agent); + return `
                                                                      +
                                                                      +
                                                                      ${ua}
                                                                      +
                                                                      IP: ${s.ip || 'unbekannt'}
                                                                      +
                                                                      +
                                                                      ${timeAgo(s.created_at)}
                                                                      + +
                                                                      `; + }).join('') : '
                                                                      Keine aktiven Sessions
                                                                      '; + document.getElementById('my-sessions-modal').classList.remove('hidden'); + } catch (err) { toast('Fehler: ' + err.message); } +} + +async function endMySession(sessionId) { + try { + await api('DELETE', `/api/my/sessions/${sessionId}`); + toast('Session beendet'); + loadMySessions(); + } catch (err) { toast('Fehler: ' + err.message); } +} + +function parseUA(ua) { + if (!ua) return 'Unbekannt'; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Firefox')) return 'Firefox'; + if (ua.includes('Safari')) return 'Safari'; + if (ua.includes('Edge')) return 'Edge'; + return ua.substring(0, 40); +} + +init().catch(error => { + recordClientIncident('startup', error?.message || String(error)); + setConnectionState(false, `Vendoo konnte nicht vollständig gestartet werden: ${error?.message || 'Unbekannter Fehler'}`); + console.error('[Vendoo] Initialisierung fehlgeschlagen', error); +}); + +// --- Vendoo 1.29.0 Publishing Hardening UI --- +publishState.jobsData = { items: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 1 }, summary: {} }; +publishState.jobPage = 1; +publishState.jobPageSize = 10; +publishState.jobStatus = ''; +publishState.jobPlatform = ''; +publishState.jobSearch = ''; +publishState.jobSelected = new Set(); +publishState.publishEvents = []; +publishState.capabilities = null; + +async function loadPublishingQueueData({ render = true } = {}) { + const params = new URLSearchParams({ page: String(publishState.jobPage), page_size: String(publishState.jobPageSize) }); + if (publishState.jobStatus) params.set('status', publishState.jobStatus); + if (publishState.jobPlatform) params.set('platform', publishState.jobPlatform); + if (publishState.jobSearch) params.set('q', publishState.jobSearch); + const [jobs, events, capabilities] = await Promise.all([ + publishSafeGet(`/api/publishing/jobs?${params}`, { items: [], pagination: { page: 1, page_size: publishState.jobPageSize, total: 0, total_pages: 1 }, summary: {} }, 2), + publishSafeGet('/api/publishing/events?limit=120', [], 2), + publishSafeGet('/api/publishing/capabilities', null, 2), + ]); + publishState.jobsData = jobs; + publishState.publishEvents = Array.isArray(events) ? events : []; + publishState.capabilities = capabilities; + const visible = new Set((jobs.items || []).map(item => item.id)); + publishState.jobSelected = new Set([...publishState.jobSelected].filter(id => visible.has(id))); + if (render) { + renderPublishWorkspace(); + renderPublishStatusRail(); + } +} + +async function loadPublishList() { + await loadPublishListLegacy(); + try { await loadPublishingQueueData(); } + catch (error) { console.warn('[Vendoo] Publishing-Queue konnte nicht geladen werden', error); } +} + +function setPublishView(view) { + publishState.view = ['smart', 'direct', 'queue', 'errors', 'scheduled'].includes(view) ? view : 'smart'; + document.querySelectorAll('[data-publish-view]').forEach(button => { + const active = button.dataset.publishView === publishState.view; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + renderPublishWorkspace(); +} + +function renderPublishWorkspace() { + if (publishState.view === 'queue') { renderQueueWorkspace(); return; } + if (publishState.view === 'errors') { renderPublishingErrorsWorkspace(); return; } + if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; } + const listing = selectedPublishListing(); + const workspace = document.getElementById('publish-workspace'); + if (!workspace) return; + if (!listing) { + workspace.innerHTML = `

                                                                      Artikel auswählen

                                                                      Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.

                                                                      `; + return; + } + if (publishState.view === 'direct') renderDirectPublishWorkspace(listing); + else renderSmartCopyLauncher(listing); +} + +function publishingStatusLabel(status) { + return ({ queued: 'Wartend', running: 'In Bearbeitung', awaiting_user: 'Wartet auf Extension', retry_wait: 'Wiederholung geplant', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', blocked: 'Blockiert', ended: 'Beendet' })[status] || status || 'Unbekannt'; +} +function publishingActionLabel(action) { + return ({ publish: 'Veröffentlichen', update: 'Aktualisieren', end: 'Beenden', sync: 'Status abgleichen' })[action] || action; +} +function publishingModeLabel(mode) { return mode === 'api' ? 'Offizielle API' : 'Browser-Extension'; } +function publishingCan(job, action) { + return Boolean(publishState.capabilities?.platforms?.[job.platform]?.[action]); +} +function publishingDate(value) { + if (!value) return '—'; + const date = new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }); +} + +function renderQueueWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const data = publishState.jobsData || { items: [], pagination: {}, summary: {} }; + const items = data.items || []; + const page = data.pagination?.page || 1; + const pages = data.pagination?.total_pages || 1; + const total = data.pagination?.total || 0; + const selectedCount = publishState.jobSelected.size; + const platformOptions = ['', 'ebay-de', 'etsy', 'vinted', 'ebay-ka'].map(value => ``).join(''); + const statusOptions = ['', 'queued', 'running', 'awaiting_user', 'retry_wait', 'published', 'drafted', 'failed', 'cancelled', 'ended'].map(value => ``).join(''); + + workspace.innerHTML = `
                                                                      +
                                                                      Zuverlässige Verarbeitung

                                                                      Publishing-Queue

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

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

                                                                      Keine Aufträge

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

                                                                      '}
                                                                      +
                                                                      Seite ${page} von ${pages}
                                                                      `; + + workspace.querySelector('[data-publishing-refresh]')?.addEventListener('click', () => loadPublishingQueueData()); + workspace.querySelector('[data-publishing-add]')?.addEventListener('click', batchEbayAdd); + workspace.querySelector('[data-publishing-search]')?.addEventListener('input', debounce(event => { publishState.jobSearch = event.target.value.trim(); publishState.jobPage = 1; loadPublishingQueueData(); }, 280)); + workspace.querySelector('[data-publishing-platform]')?.addEventListener('change', event => { publishState.jobPlatform = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-status]')?.addEventListener('change', event => { publishState.jobStatus = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-size]')?.addEventListener('change', event => { publishState.jobPageSize = Number(event.target.value); publishState.jobPage = 1; loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-prev]')?.addEventListener('click', () => { publishState.jobPage = Math.max(1, page - 1); loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-next]')?.addEventListener('click', () => { publishState.jobPage = Math.min(pages, page + 1); loadPublishingQueueData(); }); + workspace.querySelector('[data-publishing-select-page]')?.addEventListener('change', event => { + for (const item of items) event.target.checked ? publishState.jobSelected.add(item.id) : publishState.jobSelected.delete(item.id); + renderQueueWorkspace(); + }); + workspace.querySelectorAll('[data-publishing-check]').forEach(input => input.addEventListener('change', event => { + event.target.checked ? publishState.jobSelected.add(event.target.dataset.publishingCheck) : publishState.jobSelected.delete(event.target.dataset.publishingCheck); + renderQueueWorkspace(); + })); + workspace.querySelector('[data-publishing-delete-selected]')?.addEventListener('click', deleteSelectedPublishingJobs); + workspace.querySelectorAll('[data-publishing-action]').forEach(button => button.addEventListener('click', () => runPublishingJobAction(button.dataset.publishingId, button.dataset.publishingAction))); +} + +function renderPublishingJobRow(job) { + const canCancel = ['queued', 'running', 'awaiting_user', 'retry_wait'].includes(job.status); + const canRetry = ['failed', 'blocked', 'cancelled'].includes(job.status); + const canDelete = ['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked'].includes(job.status); + const isLive = job.status === 'published'; + return `
                                                                      + +
                                                                      ${escapeHtml(String(job.sku || 'VD').slice(-2))}
                                                                      ${escapeHtml(job.title || 'Ohne Titel')}${escapeHtml(job.sku || '')} · ${escapeHtml(platformLabel(job.platform))}
                                                                      +
                                                                      Aktion${escapeHtml(publishingActionLabel(job.action))}${escapeHtml(publishingModeLabel(job.mode))}
                                                                      +
                                                                      Status${escapeHtml(publishingStatusLabel(job.status))}${escapeHtml(publishingDate(job.updated_at))}
                                                                      +
                                                                      Versuche${Number(job.retries)}/${Number(job.max_retries)}${job.next_attempt_at ? `Nächster: ${escapeHtml(publishingDate(job.next_attempt_at))}` : ''}
                                                                      +
                                                                      ${job.error_message ? `${escapeHtml(job.error_message)}` : job.external_id ? `Externe ID: ${escapeHtml(job.external_id)}` : 'Keine Fehler'}
                                                                      +
                                                                      + ${job.external_url ? `` : ''} + ${isLive && publishingCan(job, 'sync') ? `` : ''} + ${isLive && publishingCan(job, 'update') ? `` : ''} + ${isLive && publishingCan(job, 'end') ? `` : ''} + ${canRetry ? `` : ''} + ${canCancel ? `` : ''} + ${canDelete ? `` : ''} +
                                                                      +
                                                                      `; +} + +async function runPublishingJobAction(id, action) { + try { + if (action === 'retry') await api('POST', `/api/publishing/jobs/${id}/retry`); + else if (action === 'cancel') await api('POST', `/api/publishing/jobs/${id}/cancel`); + else if (action === 'delete') { + if (!confirm('Diesen abgeschlossenen Publishing-Auftrag aus dem Protokoll löschen? Das externe Angebot bleibt unberührt.')) return; + await api('DELETE', '/api/publishing/jobs', { ids: [id] }); + } else if (['sync', 'update', 'end'].includes(action)) { + if (action === 'end' && !confirm('Das externe Angebot wirklich beenden?')) return; + await api('POST', `/api/publishing/jobs/${id}/action`, { action }); + } + toast(action === 'sync' ? 'Statusabgleich eingereiht' : action === 'update' ? 'Aktualisierung eingereiht' : action === 'end' ? 'Beenden eingereiht' : 'Auftrag aktualisiert'); + await loadPublishingQueueData(); + } catch (error) { toast(`Publishing-Aktion fehlgeschlagen: ${error.message}`); } +} + +async function deleteSelectedPublishingJobs() { + const ids = [...publishState.jobSelected]; + if (!ids.length || !confirm(`${ids.length} abgeschlossene Publishing-Aufträge aus dem lokalen Protokoll löschen?`)) return; + try { + const result = await api('DELETE', '/api/publishing/jobs', { ids }); + toast(`${result.deleted} Aufträge gelöscht`); + publishState.jobSelected.clear(); + await loadPublishingQueueData(); + } catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); } +} + +function renderPublishingErrorsWorkspace() { + const workspace = document.getElementById('publish-workspace'); + const events = publishState.publishEvents || []; + const errors = events.filter(event => event.level === 'error' || ['failed', 'blocked', 'retry_wait'].includes(event.event_type)); + workspace.innerHTML = `
                                                                      Diagnose & Audit

                                                                      Fehlerzentrale und Veröffentlichungsprotokoll

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

                                                                      +
                                                                      ${errors.length}Fehlerereignisse
                                                                      ${events.filter(item => item.event_type === 'completed').length}Erfolgreiche Abschlüsse
                                                                      ${events.filter(item => item.event_type === 'retry_wait').length}Automatische Wiederholungen
                                                                      +
                                                                      ${events.length ? events.map(renderPublishingEvent).join('') : '

                                                                      Keine Ereignisse

                                                                      Neue Veröffentlichungsaktivitäten erscheinen hier.

                                                                      '}
                                                                      `; + workspace.querySelector('[data-publishing-events-refresh]')?.addEventListener('click', () => loadPublishingQueueData()); + workspace.querySelectorAll('[data-event-open-job]').forEach(button => button.addEventListener('click', () => { + publishState.jobSearch = button.dataset.eventOpenJob; + publishState.jobStatus = ''; + publishState.jobPage = 1; + setPublishView('queue'); + loadPublishingQueueData(); + })); +} + +function renderPublishingEvent(event) { + return `
                                                                      + +
                                                                      ${escapeHtml(event.message)}${escapeHtml(event.title || event.sku || '')} · ${escapeHtml(platformLabel(event.platform))} · ${escapeHtml(publishingDate(event.created_at))}
                                                                      + ${escapeHtml(event.event_type)} + ${event.job_id ? `` : ''} +
                                                                      `; +} + +async function batchEbayAdd() { + const ids = [...publishState.selected]; + if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId); + if (!ids.length) return; + let created = 0; + let duplicates = 0; + let failed = 0; + for (const id of ids) { + const listing = publishState.listings.find(item => item.id === id) || publishState.allFiltered.find(item => item.id === id); + if (!listing) continue; + try { + const result = await api('POST', '/api/publishing/jobs', { listing_id: id, platform: listing.platform, action: 'publish' }); + created += result.jobs?.length || 0; + duplicates += result.duplicates || 0; + } catch (error) { + if (error.code === 'DUPLICATE_PUBLISH' || /bereits.*veröffentlicht/i.test(error.message)) duplicates++; + else failed++; + } + } + toast(`${created} Auftrag${created === 1 ? '' : 'e'} eingereiht${duplicates ? ` · ${duplicates} bereits vorhanden/live` : ''}${failed ? ` · ${failed} fehlgeschlagen` : ''}`); + publishState.view = 'queue'; + await loadPublishList(); + setPublishView('queue'); +} + +async function processEbayQueue() { + setPublishView('queue'); + await loadPublishingQueueData(); +} +async function retryEbayQueue() { + setPublishView('errors'); + await loadPublishingQueueData(); +} +async function openEbayQueue() { + setPublishView('queue'); + await loadPublishingQueueData(); +} + +function publishQueueCounts() { + const summary = publishState.jobsData?.summary || {}; + return { + pending: Number(summary.queued || 0) + Number(summary.awaiting_user || 0) + Number(summary.retry_wait || 0), + processing: Number(summary.running || 0), + published: Number(summary.published || 0), + failed: Number(summary.failed || 0) + Number(summary.blocked || 0), + }; +} + +function renderPublishStatusRail() { + const counts = publishQueueCounts(); + const summary = publishState.jobsData?.summary || {}; + const workflowPublished = publishState.workflow?.stages?.published?.count || 0; + const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length; + const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; }; + setText('publish-status-pending', counts.pending); + setText('publish-status-processing', counts.processing); + setText('publish-status-published', Math.max(counts.published, workflowPublished)); + setText('publish-status-failed', counts.failed); + setText('publish-status-scheduled', publishState.scheduled.length); + setText('publish-direct-count', directEligible); + setText('publish-queue-count', publishState.jobsData?.pagination?.total || 0); + setText('publish-error-count', counts.failed); + setText('publish-scheduled-count', publishState.scheduled.length); + setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler prüfen` : 'Keine Fehler'); + setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen'); + const container = document.getElementById('publish-activity'); + const events = publishState.publishEvents || []; + if (container) container.innerHTML = events.length ? events.slice(0, 6).map(event => `
                                                                      ${event.level === 'error' ? '!' : event.event_type === 'completed' ? '✓' : '•'}
                                                                      ${escapeHtml(event.message || '')}${relativeTime(event.created_at)}
                                                                      `).join('') : '
                                                                      Noch keine Publishing-Aktivität
                                                                      '; +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/brand/favicons/android-chrome-192x192.png b/public/brand/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/public/brand/favicons/android-chrome-192x192.png differ diff --git a/public/brand/favicons/android-chrome-512x512.png b/public/brand/favicons/android-chrome-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/public/brand/favicons/android-chrome-512x512.png differ diff --git a/public/brand/favicons/apple-touch-icon.png b/public/brand/favicons/apple-touch-icon.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/public/brand/favicons/apple-touch-icon.png differ diff --git a/public/brand/favicons/browserconfig.xml b/public/brand/favicons/browserconfig.xml new file mode 100644 index 0000000..fa0e64f --- /dev/null +++ b/public/brand/favicons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #faf6f2 + + + diff --git a/public/brand/favicons/favicon-16x16.png b/public/brand/favicons/favicon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/public/brand/favicons/favicon-16x16.png differ diff --git a/public/brand/favicons/favicon-32x32.png b/public/brand/favicons/favicon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/public/brand/favicons/favicon-32x32.png differ diff --git a/public/brand/favicons/favicon-48x48.png b/public/brand/favicons/favicon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/public/brand/favicons/favicon-48x48.png differ diff --git a/public/brand/favicons/favicon.ico b/public/brand/favicons/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/public/brand/favicons/favicon.ico differ diff --git a/public/brand/favicons/html-snippet.txt b/public/brand/favicons/html-snippet.txt new file mode 100644 index 0000000..753dafb --- /dev/null +++ b/public/brand/favicons/html-snippet.txt @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/brand/favicons/mstile-150x150.png b/public/brand/favicons/mstile-150x150.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/public/brand/favicons/mstile-150x150.png differ diff --git a/public/brand/favicons/site.webmanifest b/public/brand/favicons/site.webmanifest new file mode 100644 index 0000000..4d5086d --- /dev/null +++ b/public/brand/favicons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Vendoo", + "short_name": "Vendoo", + "icons": [ + { + "src": "/brand/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/brand/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#FAF6F2", + "background_color": "#FAF6F2", + "display": "standalone" +} \ No newline at end of file diff --git a/public/brand/icons/vendoo-icon-120x120.png b/public/brand/icons/vendoo-icon-120x120.png new file mode 100644 index 0000000..643e525 Binary files /dev/null and b/public/brand/icons/vendoo-icon-120x120.png differ diff --git a/public/brand/icons/vendoo-icon-128x128.png b/public/brand/icons/vendoo-icon-128x128.png new file mode 100644 index 0000000..6c1130d Binary files /dev/null and b/public/brand/icons/vendoo-icon-128x128.png differ diff --git a/public/brand/icons/vendoo-icon-144x144.png b/public/brand/icons/vendoo-icon-144x144.png new file mode 100644 index 0000000..8467167 Binary files /dev/null and b/public/brand/icons/vendoo-icon-144x144.png differ diff --git a/public/brand/icons/vendoo-icon-152x152.png b/public/brand/icons/vendoo-icon-152x152.png new file mode 100644 index 0000000..da26ce2 Binary files /dev/null and b/public/brand/icons/vendoo-icon-152x152.png differ diff --git a/public/brand/icons/vendoo-icon-167x167.png b/public/brand/icons/vendoo-icon-167x167.png new file mode 100644 index 0000000..7bd6ca2 Binary files /dev/null and b/public/brand/icons/vendoo-icon-167x167.png differ diff --git a/public/brand/icons/vendoo-icon-16x16.png b/public/brand/icons/vendoo-icon-16x16.png new file mode 100644 index 0000000..77c18a9 Binary files /dev/null and b/public/brand/icons/vendoo-icon-16x16.png differ diff --git a/public/brand/icons/vendoo-icon-180x180.png b/public/brand/icons/vendoo-icon-180x180.png new file mode 100644 index 0000000..575c8c7 Binary files /dev/null and b/public/brand/icons/vendoo-icon-180x180.png differ diff --git a/public/brand/icons/vendoo-icon-192x192.png b/public/brand/icons/vendoo-icon-192x192.png new file mode 100644 index 0000000..a127a07 Binary files /dev/null and b/public/brand/icons/vendoo-icon-192x192.png differ diff --git a/public/brand/icons/vendoo-icon-256x256.png b/public/brand/icons/vendoo-icon-256x256.png new file mode 100644 index 0000000..98a4ed0 Binary files /dev/null and b/public/brand/icons/vendoo-icon-256x256.png differ diff --git a/public/brand/icons/vendoo-icon-32x32.png b/public/brand/icons/vendoo-icon-32x32.png new file mode 100644 index 0000000..71bb673 Binary files /dev/null and b/public/brand/icons/vendoo-icon-32x32.png differ diff --git a/public/brand/icons/vendoo-icon-384x384.png b/public/brand/icons/vendoo-icon-384x384.png new file mode 100644 index 0000000..be778a5 Binary files /dev/null and b/public/brand/icons/vendoo-icon-384x384.png differ diff --git a/public/brand/icons/vendoo-icon-48x48.png b/public/brand/icons/vendoo-icon-48x48.png new file mode 100644 index 0000000..503afc3 Binary files /dev/null and b/public/brand/icons/vendoo-icon-48x48.png differ diff --git a/public/brand/icons/vendoo-icon-512x512.png b/public/brand/icons/vendoo-icon-512x512.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/public/brand/icons/vendoo-icon-512x512.png differ diff --git a/public/brand/icons/vendoo-icon-64x64.png b/public/brand/icons/vendoo-icon-64x64.png new file mode 100644 index 0000000..5f7da83 Binary files /dev/null and b/public/brand/icons/vendoo-icon-64x64.png differ diff --git a/public/brand/icons/vendoo-icon-72x72.png b/public/brand/icons/vendoo-icon-72x72.png new file mode 100644 index 0000000..9882902 Binary files /dev/null and b/public/brand/icons/vendoo-icon-72x72.png differ diff --git a/public/brand/icons/vendoo-icon-96x96.png b/public/brand/icons/vendoo-icon-96x96.png new file mode 100644 index 0000000..32b38d3 Binary files /dev/null and b/public/brand/icons/vendoo-icon-96x96.png differ diff --git a/public/brand/icons/vendoo-icon-original-1254.png b/public/brand/icons/vendoo-icon-original-1254.png new file mode 100644 index 0000000..ea50279 Binary files /dev/null and b/public/brand/icons/vendoo-icon-original-1254.png differ diff --git a/public/brand/logo/vendoo-logo-cropped.png b/public/brand/logo/vendoo-logo-cropped.png new file mode 100644 index 0000000..329e51a Binary files /dev/null and b/public/brand/logo/vendoo-logo-cropped.png differ diff --git a/public/brand/logo/vendoo-logo-original.png b/public/brand/logo/vendoo-logo-original.png new file mode 100644 index 0000000..44ac64d Binary files /dev/null and b/public/brand/logo/vendoo-logo-original.png differ diff --git a/public/brand/logo/vendoo-logo-transparent.png b/public/brand/logo/vendoo-logo-transparent.png new file mode 100644 index 0000000..2449e44 Binary files /dev/null and b/public/brand/logo/vendoo-logo-transparent.png differ diff --git a/public/brand/logo/vendoo-wordmark-transparent.png b/public/brand/logo/vendoo-wordmark-transparent.png new file mode 100644 index 0000000..ad76562 Binary files /dev/null and b/public/brand/logo/vendoo-wordmark-transparent.png differ diff --git a/public/core/api-client.js b/public/core/api-client.js new file mode 100644 index 0000000..f4761e9 --- /dev/null +++ b/public/core/api-client.js @@ -0,0 +1,40 @@ +export class VendooApiError extends Error { + constructor(message, { status = 0, code = 'API_ERROR', requestId = null, details = null } = {}) { + super(message); + this.name = 'VendooApiError'; + this.status = status; + this.code = code; + this.requestId = requestId; + this.details = details; + } +} + +function readCookie(name) { + const prefix = `${encodeURIComponent(name)}=`; + return document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith(prefix))?.slice(prefix.length) || ''; +} + +export async function apiRequest(path, { method = 'GET', body, headers = {}, signal } = {}) { + const verb = String(method).toUpperCase(); + const requestHeaders = new Headers(headers); + requestHeaders.set('Accept', 'application/json'); + if (body !== undefined) requestHeaders.set('Content-Type', 'application/json'); + if (!['GET', 'HEAD', 'OPTIONS'].includes(verb)) requestHeaders.set('X-CSRF-Token', decodeURIComponent(readCookie('csrf_token'))); + const response = await fetch(path, { + method: verb, + credentials: 'same-origin', + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + redirect: 'error', + }); + const requestId = response.headers.get('x-vendoo-request-id'); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new VendooApiError(payload.error || `HTTP ${response.status}`, { + status: response.status, + code: payload.code || 'API_ERROR', + requestId: payload.request_id || requestId, + details: payload.details || null, + }); + return payload; +} diff --git a/public/core/frontend-module-registry.js b/public/core/frontend-module-registry.js new file mode 100644 index 0000000..80d1caa --- /dev/null +++ b/public/core/frontend-module-registry.js @@ -0,0 +1,51 @@ +const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/; + +export class FrontendModuleRegistry { + #modules = new Map(); + #active = null; + + register(definition) { + const id = String(definition?.id || ''); + if (!MODULE_ID.test(id)) throw new Error(`Ungültige Frontend-Modul-ID: ${id}`); + if (this.#modules.has(id)) throw new Error(`Frontend-Modul bereits registriert: ${id}`); + if (typeof definition.mount !== 'function') throw new Error(`Frontend-Modul benötigt mount(): ${id}`); + this.#modules.set(id, Object.freeze({ + id, + version: String(definition.version || '1.0.0'), + status: String(definition.status || 'native'), + mount: definition.mount, + unmount: typeof definition.unmount === 'function' ? definition.unmount : async () => {}, + })); + } + + async mount(id, root, services = {}) { + if (!(root instanceof Element)) throw new Error('Frontend-Modul benötigt ein gültiges Root-Element.'); + const module = this.#modules.get(id); + if (!module) throw new Error(`Frontend-Modul ist nicht registriert: ${id}`); + await this.unmount(); + const controller = new AbortController(); + const context = Object.freeze({ + root, + services: Object.freeze({ ...services }), + signal: controller.signal, + listen(target, event, handler, options = {}) { + target.addEventListener(event, handler, { ...options, signal: controller.signal }); + }, + }); + await module.mount(context); + this.#active = { module, context, controller }; + return module; + } + + async unmount() { + if (!this.#active) return; + const active = this.#active; + this.#active = null; + active.controller.abort(); + await active.module.unmount(active.context); + } + + list() { + return [...this.#modules.values()].map(({ mount: _mount, unmount: _unmount, ...module }) => ({ ...module })); + } +} diff --git a/public/core/safe-dom.js b/public/core/safe-dom.js new file mode 100644 index 0000000..09e58ff --- /dev/null +++ b/public/core/safe-dom.js @@ -0,0 +1,26 @@ +export function setText(node, value) { + if (!(node instanceof Node)) throw new TypeError('DOM-Ziel fehlt.'); + node.textContent = value == null ? '' : String(value); + return node; +} + +export function createElement(tagName, { className = '', text = '', attributes = {}, children = [] } = {}) { + if (!/^[a-z][a-z0-9-]*$/i.test(String(tagName))) throw new TypeError('Ungültiger Elementname.'); + const element = document.createElement(tagName); + if (className) element.className = String(className); + if (text !== '') setText(element, text); + for (const [name, value] of Object.entries(attributes)) { + if (!/^(aria-[a-z-]+|data-[a-z-]+|id|role|title|type|name|value|href|src|alt|tabindex)$/i.test(name)) { + throw new TypeError(`Attribut ist nicht freigegeben: ${name}`); + } + if (['href', 'src'].includes(name.toLowerCase())) { + const url = new URL(String(value), window.location.origin); + if (!['http:', 'https:', 'blob:'].includes(url.protocol) || (['http:', 'https:'].includes(url.protocol) && url.origin !== window.location.origin)) { + throw new TypeError(`Externe oder unsichere URL ist nicht erlaubt: ${name}`); + } + } + element.setAttribute(name, String(value)); + } + for (const child of children) element.append(child); + return element; +} diff --git a/public/design-system/theme-contract.json b/public/design-system/theme-contract.json new file mode 100644 index 0000000..6e5e353 --- /dev/null +++ b/public/design-system/theme-contract.json @@ -0,0 +1,333 @@ +{ + "schemaVersion": 1, + "contractVersion": "1.1.0", + "themes": [ + "light", + "dark", + "contrast" + ], + "tokens": { + "color.canvas": { + "css": "--vd-canvas", + "type": "color", + "customizable": true, + "label": "Seitenhintergrund", + "group": "Farben" + }, + "color.surface": { + "css": "--vd-surface", + "type": "color", + "customizable": true, + "label": "Fläche", + "group": "Farben" + }, + "color.surfaceRaised": { + "css": "--vd-surface-raised", + "type": "color", + "customizable": true, + "label": "Erhöhte Fläche", + "group": "Farben" + }, + "color.surfaceMuted": { + "css": "--vd-surface-muted", + "type": "color", + "customizable": true, + "label": "Gedämpfte Fläche", + "group": "Farben" + }, + "color.surfaceQuiet": { + "css": "--vd-surface-quiet", + "type": "color", + "customizable": true, + "label": "Ruhige Fläche", + "group": "Farben" + }, + "color.line": { + "css": "--vd-line", + "type": "color", + "customizable": true, + "label": "Trennlinie", + "group": "Farben" + }, + "color.lineStrong": { + "css": "--vd-line-strong", + "type": "color", + "customizable": true, + "label": "Starke Trennlinie", + "group": "Farben" + }, + "color.ink": { + "css": "--vd-ink", + "type": "color", + "customizable": true, + "label": "Primärtext", + "group": "Farben" + }, + "color.inkSecondary": { + "css": "--vd-ink-secondary", + "type": "color", + "customizable": true, + "label": "Sekundärtext", + "group": "Farben" + }, + "color.inkMuted": { + "css": "--vd-ink-muted", + "type": "color", + "customizable": true, + "label": "Hinweistext", + "group": "Farben" + }, + "color.brand": { + "css": "--vd-brand", + "type": "color", + "customizable": true, + "label": "Akzentfarbe", + "group": "Marke" + }, + "color.brandStrong": { + "css": "--vd-brand-strong", + "type": "color", + "customizable": true, + "label": "Akzentfarbe kräftig", + "group": "Marke" + }, + "color.brandSoft": { + "css": "--vd-brand-soft", + "type": "color", + "customizable": false, + "label": "color.brandSoft", + "group": "Color" + }, + "color.success": { + "css": "--vd-success", + "type": "color", + "customizable": true, + "label": "Erfolg", + "group": "Status" + }, + "color.successSoft": { + "css": "--vd-success-soft", + "type": "color", + "customizable": false, + "label": "color.successSoft", + "group": "Color" + }, + "color.warning": { + "css": "--vd-warning", + "type": "color", + "customizable": true, + "label": "Warnung", + "group": "Status" + }, + "color.warningSoft": { + "css": "--vd-warning-soft", + "type": "color", + "customizable": false, + "label": "color.warningSoft", + "group": "Color" + }, + "color.danger": { + "css": "--vd-danger", + "type": "color", + "customizable": true, + "label": "Gefahr", + "group": "Status" + }, + "color.dangerSoft": { + "css": "--vd-danger-soft", + "type": "color", + "customizable": false, + "label": "color.dangerSoft", + "group": "Color" + }, + "color.info": { + "css": "--vd-info", + "type": "color", + "customizable": true, + "label": "Information", + "group": "Status" + }, + "color.infoSoft": { + "css": "--vd-info-soft", + "type": "color", + "customizable": false, + "label": "color.infoSoft", + "group": "Color" + }, + "color.focus": { + "css": "--vd-focus", + "type": "color", + "customizable": false, + "label": "color.focus", + "group": "Color" + }, + "shadow.float": { + "css": "--vd-shadow-float", + "type": "shadow", + "customizable": false, + "label": "shadow.float", + "group": "Shadow" + }, + "shadow.soft": { + "css": "--vd-shadow-soft", + "type": "shadow", + "customizable": false, + "label": "shadow.soft", + "group": "Shadow" + }, + "radius.xs": { + "css": "--vd-radius-xs", + "type": "dimension", + "customizable": true, + "label": "Rundung klein", + "group": "Form", + "min": 0, + "max": 16 + }, + "radius.sm": { + "css": "--vd-radius-sm", + "type": "dimension", + "customizable": true, + "label": "Rundung mittel", + "group": "Form", + "min": 0, + "max": 20 + }, + "radius.md": { + "css": "--vd-radius-md", + "type": "dimension", + "customizable": true, + "label": "Rundung groß", + "group": "Form", + "min": 0, + "max": 28 + }, + "layout.sidebarWidth": { + "css": "--vd-sidebar-width", + "type": "dimension", + "customizable": false, + "label": "layout.sidebarWidth", + "group": "Layout" + }, + "layout.topbarHeight": { + "css": "--vd-topbar-height", + "type": "dimension", + "customizable": false, + "label": "layout.topbarHeight", + "group": "Layout" + }, + "density.controlHeight": { + "css": "--vd-control-height", + "type": "dimension", + "customizable": true, + "label": "Bedienelementhöhe", + "group": "Dichte", + "min": 32, + "max": 52 + }, + "density.contentGap": { + "css": "--vd-content-gap", + "type": "dimension", + "customizable": true, + "label": "Inhaltsabstand", + "group": "Dichte", + "min": 8, + "max": 32 + }, + "motion.fast": { + "css": "--vd-motion-fast", + "type": "duration", + "customizable": false, + "label": "motion.fast", + "group": "Motion" + }, + "motion.normal": { + "css": "--vd-motion-normal", + "type": "duration", + "customizable": false, + "label": "motion.normal", + "group": "Motion" + }, + "component.buttonHeight": { + "css": "--vd-component-button-height", + "type": "dimension", + "customizable": true, + "label": "Button-Höhe", + "group": "Komponenten", + "min": 32, + "max": 56 + }, + "component.buttonRadius": { + "css": "--vd-component-button-radius", + "type": "dimension", + "customizable": true, + "label": "Button-Rundung", + "group": "Komponenten", + "min": 0, + "max": 24 + }, + "component.inputHeight": { + "css": "--vd-component-input-height", + "type": "dimension", + "customizable": true, + "label": "Eingabefeld-Höhe", + "group": "Komponenten", + "min": 34, + "max": 58 + }, + "component.inputRadius": { + "css": "--vd-component-input-radius", + "type": "dimension", + "customizable": true, + "label": "Eingabefeld-Rundung", + "group": "Komponenten", + "min": 0, + "max": 24 + }, + "component.cardRadius": { + "css": "--vd-component-card-radius", + "type": "dimension", + "customizable": true, + "label": "Karten-Rundung", + "group": "Komponenten", + "min": 0, + "max": 32 + }, + "component.cardPadding": { + "css": "--vd-component-card-padding", + "type": "dimension", + "customizable": true, + "label": "Karten-Innenabstand", + "group": "Komponenten", + "min": 8, + "max": 32 + }, + "component.dialogRadius": { + "css": "--vd-component-dialog-radius", + "type": "dimension", + "customizable": true, + "label": "Dialog-Rundung", + "group": "Komponenten", + "min": 0, + "max": 36 + }, + "component.focusWidth": { + "css": "--vd-component-focus-width", + "type": "dimension", + "customizable": true, + "label": "Fokus-Ring Stärke", + "group": "Barrierefreiheit", + "min": 2, + "max": 6 + }, + "component.touchTarget": { + "css": "--vd-component-touch-target", + "type": "dimension", + "customizable": true, + "label": "Mindest-Zielgröße", + "group": "Barrierefreiheit", + "min": 40, + "max": 56 + } + } +} diff --git a/public/design-system/theme-runtime.js b/public/design-system/theme-runtime.js new file mode 100644 index 0000000..69c02e7 --- /dev/null +++ b/public/design-system/theme-runtime.js @@ -0,0 +1,90 @@ +const STORAGE_KEY = 'vendoo-theme-state-v2'; +const LEGACY_KEY = 'vendoo-theme'; +const BUILTIN = new Set(['light', 'dark', 'contrast', 'system']); +let activeCustomProperties = []; + +function mediaDark() { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches; +} + +function mediaReducedMotion() { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +function clearCustomProperties() { + for (const property of activeCustomProperties) document.documentElement.style.removeProperty(property); + activeCustomProperties = []; +} + +function effectiveBuiltin(themeId) { + return themeId === 'system' ? (mediaDark() ? 'dark' : 'light') : themeId; +} + +function normalizeState(input = {}) { + const themeId = String(input.theme_id || input.themeId || 'system'); + return { + theme_id: themeId, + density: ['compact', 'comfortable', 'spacious'].includes(input.density) ? input.density : 'comfortable', + reduced_motion: ['system', 'reduce', 'allow'].includes(input.reduced_motion || input.reducedMotion) ? (input.reduced_motion || input.reducedMotion) : 'system', + custom_theme: input.custom_theme || input.customTheme || null, + }; +} + +function applyState(input, { persist = true, dispatch = true } = {}) { + const state = normalizeState(input); + clearCustomProperties(); + const custom = state.custom_theme && state.custom_theme.id === state.theme_id ? state.custom_theme : null; + const baseTheme = custom ? custom.base_theme : state.theme_id; + const resolved = effectiveBuiltin(BUILTIN.has(baseTheme) ? baseTheme : 'light'); + document.documentElement.dataset.theme = resolved; + document.documentElement.dataset.themePreference = state.theme_id; + document.documentElement.dataset.density = state.density; + const reduce = state.reduced_motion === 'reduce' || (state.reduced_motion === 'system' && mediaReducedMotion()); + document.documentElement.dataset.reducedMotion = reduce ? 'true' : 'false'; + if (custom?.css_values) { + for (const [property, value] of Object.entries(custom.css_values)) { + if (!/^--vd-[a-z0-9-]+$/.test(property)) continue; + document.documentElement.style.setProperty(property, String(value)); + activeCustomProperties.push(property); + } + } + if (persist) localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + if (dispatch) window.dispatchEvent(new CustomEvent('vendoo:theme-changed', { detail: { ...state, resolved } })); + return { ...state, resolved }; +} + +function readStoredState() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (parsed) return normalizeState(parsed); + } catch {} + const legacy = localStorage.getItem(LEGACY_KEY); + return normalizeState({ theme_id: BUILTIN.has(legacy) ? legacy : 'system' }); +} + +function previewTheme({ base_theme = 'light', css_values = {}, density = 'comfortable', reduced_motion = 'system' } = {}) { + return applyState({ theme_id: '__preview__', density, reduced_motion, custom_theme: { id: '__preview__', base_theme, css_values } }, { persist: false }); +} + +function restoreStored() { + return applyState(readStoredState(), { persist: false }); +} + +applyState(readStoredState(), { persist: false, dispatch: false }); +window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', () => { + if (document.documentElement.dataset.themePreference === 'system') restoreStored(); +}); +window.matchMedia?.('(prefers-reduced-motion: reduce)').addEventListener?.('change', () => restoreStored()); + +window.VendooTheme = Object.freeze({ + get preference() { return document.documentElement.dataset.themePreference || 'system'; }, + get resolved() { return document.documentElement.dataset.theme || 'light'; }, + applyState, + previewTheme, + restoreStored, + setTheme(themeId, { persist = true } = {}) { + const current = readStoredState(); + return applyState({ ...current, theme_id: BUILTIN.has(themeId) ? themeId : 'system', custom_theme: null }, { persist }); + }, + supported: Object.freeze([...BUILTIN]), +}); diff --git a/public/design-system/tokens.css b/public/design-system/tokens.css new file mode 100644 index 0000000..6e44884 --- /dev/null +++ b/public/design-system/tokens.css @@ -0,0 +1,140 @@ +/* Automatisch generiert aus design-system/tokens/source.json. Nicht direkt bearbeiten. */ +/* Contract 1.1.0 */ +:root { + --vd-canvas: #f7f5f1; + --vd-surface: #fffdf9; + --vd-surface-raised: #ffffff; + --vd-surface-muted: #f1efea; + --vd-surface-quiet: #ebe8e1; + --vd-line: #e2ded6; + --vd-line-strong: #cbc5ba; + --vd-ink: #171714; + --vd-ink-secondary: #5f5b54; + --vd-ink-muted: #8c877e; + --vd-brand: #e33a16; + --vd-brand-strong: #c92f10; + --vd-brand-soft: rgba(227, 58, 22, 0.08); + --vd-success: #2f7d50; + --vd-success-soft: #e7f2ea; + --vd-warning: #b96a23; + --vd-warning-soft: #fbefe3; + --vd-danger: #c44234; + --vd-danger-soft: #f9e8e5; + --vd-info: #3975ba; + --vd-info-soft: #e8f0f9; + --vd-focus: rgba(227, 58, 22, 0.18); + --vd-shadow-float: 0 18px 48px rgba(35, 30, 24, 0.12); + --vd-shadow-soft: 0 8px 24px rgba(35, 30, 24, 0.06); + --vd-radius-xs: 5px; + --vd-radius-sm: 8px; + --vd-radius-md: 12px; + --vd-sidebar-width: 148px; + --vd-topbar-height: 60px; + --vd-control-height: 40px; + --vd-content-gap: 16px; + --vd-motion-fast: 120ms; + --vd-motion-normal: 180ms; + --vd-component-button-height: 40px; + --vd-component-button-radius: 8px; + --vd-component-input-height: 42px; + --vd-component-input-radius: 8px; + --vd-component-card-radius: 12px; + --vd-component-card-padding: 16px; + --vd-component-dialog-radius: 16px; + --vd-component-focus-width: 3px; + --vd-component-touch-target: 44px; +} + +[data-theme="dark"] { + --vd-canvas: #171714; + --vd-surface: #1d1d19; + --vd-surface-raised: #22221e; + --vd-surface-muted: #292923; + --vd-surface-quiet: #303029; + --vd-line: #37372f; + --vd-line-strong: #4a4940; + --vd-ink: #f2efe8; + --vd-ink-secondary: #c3beb4; + --vd-ink-muted: #8f8a80; + --vd-brand: #ff5a32; + --vd-brand-strong: #ff744f; + --vd-brand-soft: rgba(255, 90, 50, 0.12); + --vd-success: #75bd8c; + --vd-success-soft: #203629; + --vd-warning: #e2a35f; + --vd-warning-soft: #3b2e20; + --vd-danger: #ed7568; + --vd-danger-soft: #3b2523; + --vd-info: #78a9df; + --vd-info-soft: #202f40; + --vd-focus: rgba(255, 90, 50, 0.26); + --vd-shadow-float: 0 20px 56px rgba(0, 0, 0, 0.38); + --vd-shadow-soft: 0 8px 26px rgba(0, 0, 0, 0.20); + --vd-radius-xs: 5px; + --vd-radius-sm: 8px; + --vd-radius-md: 12px; + --vd-sidebar-width: 148px; + --vd-topbar-height: 60px; + --vd-control-height: 40px; + --vd-content-gap: 16px; + --vd-motion-fast: 120ms; + --vd-motion-normal: 180ms; + --vd-component-button-height: 40px; + --vd-component-button-radius: 8px; + --vd-component-input-height: 42px; + --vd-component-input-radius: 8px; + --vd-component-card-radius: 12px; + --vd-component-card-padding: 16px; + --vd-component-dialog-radius: 16px; + --vd-component-focus-width: 3px; + --vd-component-touch-target: 44px; +} + +[data-theme="contrast"] { + --vd-canvas: #000000; + --vd-surface: #090909; + --vd-surface-raised: #111111; + --vd-surface-muted: #1b1b1b; + --vd-surface-quiet: #242424; + --vd-line: #ffffff; + --vd-line-strong: #ffffff; + --vd-ink: #ffffff; + --vd-ink-secondary: #ffffff; + --vd-ink-muted: #f2f2f2; + --vd-brand: #ffe600; + --vd-brand-strong: #ffffff; + --vd-brand-soft: rgba(255, 230, 0, 0.20); + --vd-success: #66ff99; + --vd-success-soft: #002d12; + --vd-warning: #ffd000; + --vd-warning-soft: #332a00; + --vd-danger: #ff6b6b; + --vd-danger-soft: #3a0000; + --vd-info: #72c7ff; + --vd-info-soft: #00253d; + --vd-focus: rgba(255, 230, 0, 0.55); + --vd-shadow-float: 0 0 0 2px #ffffff; + --vd-shadow-soft: 0 0 0 1px #ffffff; + --vd-radius-xs: 0px; + --vd-radius-sm: 2px; + --vd-radius-md: 4px; + --vd-sidebar-width: 168px; + --vd-topbar-height: 64px; + --vd-control-height: 44px; + --vd-content-gap: 18px; + --vd-motion-fast: 0ms; + --vd-motion-normal: 0ms; + --vd-component-button-height: 46px; + --vd-component-button-radius: 2px; + --vd-component-input-height: 48px; + --vd-component-input-radius: 2px; + --vd-component-card-radius: 4px; + --vd-component-card-padding: 18px; + --vd-component-dialog-radius: 4px; + --vd-component-focus-width: 4px; + --vd-component-touch-target: 48px; +} + +@media (prefers-reduced-motion: reduce) { + :root { --vd-motion-fast: 0ms; --vd-motion-normal: 0ms; } +} diff --git a/public/design-system/tokens/source.json b/public/design-system/tokens/source.json new file mode 100644 index 0000000..f351e87 --- /dev/null +++ b/public/design-system/tokens/source.json @@ -0,0 +1,459 @@ +{ + "schemaVersion": 1, + "contractVersion": "1.1.0", + "themes": [ + "light", + "dark", + "contrast" + ], + "tokens": { + "color.canvas": { + "css": "--vd-canvas", + "type": "color", + "customizable": true, + "light": "#f7f5f1", + "dark": "#171714", + "contrast": "#000000", + "label": "Seitenhintergrund", + "group": "Farben" + }, + "color.surface": { + "css": "--vd-surface", + "type": "color", + "customizable": true, + "light": "#fffdf9", + "dark": "#1d1d19", + "contrast": "#090909", + "label": "Fläche", + "group": "Farben" + }, + "color.surfaceRaised": { + "css": "--vd-surface-raised", + "type": "color", + "customizable": true, + "light": "#ffffff", + "dark": "#22221e", + "contrast": "#111111", + "label": "Erhöhte Fläche", + "group": "Farben" + }, + "color.surfaceMuted": { + "css": "--vd-surface-muted", + "type": "color", + "customizable": true, + "light": "#f1efea", + "dark": "#292923", + "contrast": "#1b1b1b", + "label": "Gedämpfte Fläche", + "group": "Farben" + }, + "color.surfaceQuiet": { + "css": "--vd-surface-quiet", + "type": "color", + "customizable": true, + "light": "#ebe8e1", + "dark": "#303029", + "contrast": "#242424", + "label": "Ruhige Fläche", + "group": "Farben" + }, + "color.line": { + "css": "--vd-line", + "type": "color", + "customizable": true, + "light": "#e2ded6", + "dark": "#37372f", + "contrast": "#ffffff", + "label": "Trennlinie", + "group": "Farben" + }, + "color.lineStrong": { + "css": "--vd-line-strong", + "type": "color", + "customizable": true, + "light": "#cbc5ba", + "dark": "#4a4940", + "contrast": "#ffffff", + "label": "Starke Trennlinie", + "group": "Farben" + }, + "color.ink": { + "css": "--vd-ink", + "type": "color", + "customizable": true, + "light": "#171714", + "dark": "#f2efe8", + "contrast": "#ffffff", + "label": "Primärtext", + "group": "Farben" + }, + "color.inkSecondary": { + "css": "--vd-ink-secondary", + "type": "color", + "customizable": true, + "light": "#5f5b54", + "dark": "#c3beb4", + "contrast": "#ffffff", + "label": "Sekundärtext", + "group": "Farben" + }, + "color.inkMuted": { + "css": "--vd-ink-muted", + "type": "color", + "customizable": true, + "light": "#8c877e", + "dark": "#8f8a80", + "contrast": "#f2f2f2", + "label": "Hinweistext", + "group": "Farben" + }, + "color.brand": { + "css": "--vd-brand", + "type": "color", + "customizable": true, + "light": "#e33a16", + "dark": "#ff5a32", + "contrast": "#ffe600", + "label": "Akzentfarbe", + "group": "Marke" + }, + "color.brandStrong": { + "css": "--vd-brand-strong", + "type": "color", + "customizable": true, + "light": "#c92f10", + "dark": "#ff744f", + "contrast": "#ffffff", + "label": "Akzentfarbe kräftig", + "group": "Marke" + }, + "color.brandSoft": { + "css": "--vd-brand-soft", + "type": "color", + "customizable": false, + "light": "rgba(227, 58, 22, 0.08)", + "dark": "rgba(255, 90, 50, 0.12)", + "contrast": "rgba(255, 230, 0, 0.20)", + "label": "color.brandSoft", + "group": "Color" + }, + "color.success": { + "css": "--vd-success", + "type": "color", + "customizable": true, + "light": "#2f7d50", + "dark": "#75bd8c", + "contrast": "#66ff99", + "label": "Erfolg", + "group": "Status" + }, + "color.successSoft": { + "css": "--vd-success-soft", + "type": "color", + "customizable": false, + "light": "#e7f2ea", + "dark": "#203629", + "contrast": "#002d12", + "label": "color.successSoft", + "group": "Color" + }, + "color.warning": { + "css": "--vd-warning", + "type": "color", + "customizable": true, + "light": "#b96a23", + "dark": "#e2a35f", + "contrast": "#ffd000", + "label": "Warnung", + "group": "Status" + }, + "color.warningSoft": { + "css": "--vd-warning-soft", + "type": "color", + "customizable": false, + "light": "#fbefe3", + "dark": "#3b2e20", + "contrast": "#332a00", + "label": "color.warningSoft", + "group": "Color" + }, + "color.danger": { + "css": "--vd-danger", + "type": "color", + "customizable": true, + "light": "#c44234", + "dark": "#ed7568", + "contrast": "#ff6b6b", + "label": "Gefahr", + "group": "Status" + }, + "color.dangerSoft": { + "css": "--vd-danger-soft", + "type": "color", + "customizable": false, + "light": "#f9e8e5", + "dark": "#3b2523", + "contrast": "#3a0000", + "label": "color.dangerSoft", + "group": "Color" + }, + "color.info": { + "css": "--vd-info", + "type": "color", + "customizable": true, + "light": "#3975ba", + "dark": "#78a9df", + "contrast": "#72c7ff", + "label": "Information", + "group": "Status" + }, + "color.infoSoft": { + "css": "--vd-info-soft", + "type": "color", + "customizable": false, + "light": "#e8f0f9", + "dark": "#202f40", + "contrast": "#00253d", + "label": "color.infoSoft", + "group": "Color" + }, + "color.focus": { + "css": "--vd-focus", + "type": "color", + "customizable": false, + "light": "rgba(227, 58, 22, 0.18)", + "dark": "rgba(255, 90, 50, 0.26)", + "contrast": "rgba(255, 230, 0, 0.55)", + "label": "color.focus", + "group": "Color" + }, + "shadow.float": { + "css": "--vd-shadow-float", + "type": "shadow", + "customizable": false, + "light": "0 18px 48px rgba(35, 30, 24, 0.12)", + "dark": "0 20px 56px rgba(0, 0, 0, 0.38)", + "contrast": "0 0 0 2px #ffffff", + "label": "shadow.float", + "group": "Shadow" + }, + "shadow.soft": { + "css": "--vd-shadow-soft", + "type": "shadow", + "customizable": false, + "light": "0 8px 24px rgba(35, 30, 24, 0.06)", + "dark": "0 8px 26px rgba(0, 0, 0, 0.20)", + "contrast": "0 0 0 1px #ffffff", + "label": "shadow.soft", + "group": "Shadow" + }, + "radius.xs": { + "css": "--vd-radius-xs", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 16, + "light": "5px", + "dark": "5px", + "contrast": "0px", + "label": "Rundung klein", + "group": "Form" + }, + "radius.sm": { + "css": "--vd-radius-sm", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 20, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Rundung mittel", + "group": "Form" + }, + "radius.md": { + "css": "--vd-radius-md", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 28, + "light": "12px", + "dark": "12px", + "contrast": "4px", + "label": "Rundung groß", + "group": "Form" + }, + "layout.sidebarWidth": { + "css": "--vd-sidebar-width", + "type": "dimension", + "customizable": false, + "light": "148px", + "dark": "148px", + "contrast": "168px", + "label": "layout.sidebarWidth", + "group": "Layout" + }, + "layout.topbarHeight": { + "css": "--vd-topbar-height", + "type": "dimension", + "customizable": false, + "light": "60px", + "dark": "60px", + "contrast": "64px", + "label": "layout.topbarHeight", + "group": "Layout" + }, + "density.controlHeight": { + "css": "--vd-control-height", + "type": "dimension", + "customizable": true, + "min": 32, + "max": 52, + "light": "40px", + "dark": "40px", + "contrast": "44px", + "label": "Bedienelementhöhe", + "group": "Dichte" + }, + "density.contentGap": { + "css": "--vd-content-gap", + "type": "dimension", + "customizable": true, + "min": 8, + "max": 32, + "light": "16px", + "dark": "16px", + "contrast": "18px", + "label": "Inhaltsabstand", + "group": "Dichte" + }, + "motion.fast": { + "css": "--vd-motion-fast", + "type": "duration", + "customizable": false, + "light": "120ms", + "dark": "120ms", + "contrast": "0ms", + "label": "motion.fast", + "group": "Motion" + }, + "motion.normal": { + "css": "--vd-motion-normal", + "type": "duration", + "customizable": false, + "light": "180ms", + "dark": "180ms", + "contrast": "0ms", + "label": "motion.normal", + "group": "Motion" + }, + "component.buttonHeight": { + "css": "--vd-component-button-height", + "type": "dimension", + "customizable": true, + "min": 32, + "max": 56, + "light": "40px", + "dark": "40px", + "contrast": "46px", + "label": "Button-Höhe", + "group": "Komponenten" + }, + "component.buttonRadius": { + "css": "--vd-component-button-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 24, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Button-Rundung", + "group": "Komponenten" + }, + "component.inputHeight": { + "css": "--vd-component-input-height", + "type": "dimension", + "customizable": true, + "min": 34, + "max": 58, + "light": "42px", + "dark": "42px", + "contrast": "48px", + "label": "Eingabefeld-Höhe", + "group": "Komponenten" + }, + "component.inputRadius": { + "css": "--vd-component-input-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 24, + "light": "8px", + "dark": "8px", + "contrast": "2px", + "label": "Eingabefeld-Rundung", + "group": "Komponenten" + }, + "component.cardRadius": { + "css": "--vd-component-card-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 32, + "light": "12px", + "dark": "12px", + "contrast": "4px", + "label": "Karten-Rundung", + "group": "Komponenten" + }, + "component.cardPadding": { + "css": "--vd-component-card-padding", + "type": "dimension", + "customizable": true, + "min": 8, + "max": 32, + "light": "16px", + "dark": "16px", + "contrast": "18px", + "label": "Karten-Innenabstand", + "group": "Komponenten" + }, + "component.dialogRadius": { + "css": "--vd-component-dialog-radius", + "type": "dimension", + "customizable": true, + "min": 0, + "max": 36, + "light": "16px", + "dark": "16px", + "contrast": "4px", + "label": "Dialog-Rundung", + "group": "Komponenten" + }, + "component.focusWidth": { + "css": "--vd-component-focus-width", + "type": "dimension", + "customizable": true, + "min": 2, + "max": 6, + "light": "3px", + "dark": "3px", + "contrast": "4px", + "label": "Fokus-Ring Stärke", + "group": "Barrierefreiheit" + }, + "component.touchTarget": { + "css": "--vd-component-touch-target", + "type": "dimension", + "customizable": true, + "min": 40, + "max": 56, + "light": "44px", + "dark": "44px", + "contrast": "48px", + "label": "Mindest-Zielgröße", + "group": "Barrierefreiheit" + } + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a6577ad Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c324adb --- /dev/null +++ b/public/index.html @@ -0,0 +1,2329 @@ + + + + + + + Vendoo + + + + + + + + + + + + + + + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +

                                                                      Dashboard

                                                                      + Analytics +
                                                                      +
                                                                      + + + +
                                                                      + + + +
                                                                      +
                                                                      + + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Business Intelligence

                                                                      +

                                                                      Dashboard

                                                                      +

                                                                      Dein Überblick über Umsatz, Artikel, Lagerbestand und Performance.

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      Umsatz heuteAktueller Tageswert
                                                                      +
                                                                      Verkäufe 7 TageAbgeschlossene Verkäufe
                                                                      +
                                                                      Aktive ArtikelAktuell im Bestand
                                                                      +
                                                                      EntwürfeNoch nicht veröffentlicht
                                                                      +
                                                                      LagerwertGeschätzter Gesamtwert
                                                                      +
                                                                      Publish-ErfolgsquoteErfolgreiche Versuche
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      Umsatzentwicklung
                                                                      Letzte 30 Tage
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      Umsatz nach Plattform
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      Umsatz nach KategorieVerteilung
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      Heute im FokusDirekt handlungsrelevant
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      Top Seller / BestsellerNach Umsatz
                                                                      +
                                                                      ArtikelSKUPlattformUmsatzVerkäufeStatus
                                                                      +
                                                                      +
                                                                      +
                                                                      SchnellaktionenHäufige Aufgaben
                                                                      +
                                                                      + + + + + +
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Commerce Workbench

                                                                      +

                                                                      Studio Flow

                                                                      +

                                                                      Deine nächsten Schritte – klar priorisiert und ohne Kartenüberladung.

                                                                      +
                                                                      +
                                                                      + +
                                                                      + + + + + +
                                                                      + +
                                                                      +
                                                                      + + + + + +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Bereit für den nächsten Schritt

                                                                      0
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Geplant & in Warteschlange

                                                                      0
                                                                      +
                                                                      ArtikelPlattformGeplant amStatusAktion
                                                                      +
                                                                      +
                                                                      +
                                                                      !

                                                                      Benötigt Aufmerksamkeit

                                                                      0
                                                                      +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +

                                                                      Aktivität

                                                                      +

                                                                      Letzte Aktivitäten

                                                                      Abgeschlossene Aktionen

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Listing Studio

                                                                      +

                                                                      Neuer Artikel

                                                                      +

                                                                      Fotos erfassen, AI gezielt steuern und das Ergebnis ohne Kontextwechsel fertigstellen.

                                                                      +
                                                                      +
                                                                      +
                                                                      + 1 Fotos + 2 AI + 3 Prüfen +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      + 01 +
                                                                      +

                                                                      Fotos & Medien

                                                                      +

                                                                      Das erste Bild wird als Cover verwendet.

                                                                      +
                                                                      +
                                                                      + 0 / 10 +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + Noch keine Produktfotos + Gute Bilder erhöhen Erkennungsqualität und Verkaufschance. +
                                                                      + + +
                                                                      + +
                                                                      + +
                                                                      + +
                                                                      +

                                                                      Fotos hierher ziehen
                                                                      oder klicken zum Auswählen

                                                                      + JPG, PNG, WEBP oder GIF · maximal 10 Bilder +
                                                                      + + + +
                                                                      + + + + +
                                                                      +
                                                                      +
                                                                      + AI +
                                                                      +

                                                                      Produktbild-Werkzeuge

                                                                      +

                                                                      Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus dem vorhandenen Produktfoto. Freie Prompt-Bilder findest du im separaten FLUX Studio.

                                                                      +
                                                                      +
                                                                      + Safe +
                                                                      +
                                                                      + + + + + +
                                                                      +
                                                                      Wartet auf Produktfotos.
                                                                      + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + + Foto-Check +
                                                                      + 0 / 5 +
                                                                      +
                                                                        +
                                                                      • Mindestens drei Perspektiven
                                                                      • +
                                                                      • Klares Coverfoto
                                                                      • +
                                                                      • Detail- und Etikettfoto
                                                                      • +
                                                                      • Ruhiger Hintergrund empfohlen
                                                                      • +
                                                                      • Reihenfolge per Drag & Drop
                                                                      • +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + 02 +
                                                                      +

                                                                      AI-Generator

                                                                      +

                                                                      Lege Ziel, Sprache und Tonalität fest.

                                                                      +
                                                                      +
                                                                      + Assistiert +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + + Optimiert Aufbau, Ton und Pflichtfelder. +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + + Wähle den bevorzugten Analyse-Dienst. +
                                                                      +
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + + Übernimmt Plattform, Sprache, Hinweise und Standard-Tags. +
                                                                      + + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + + Nur relevante Besonderheiten für diesen Artikel. +
                                                                      + 0 / 500 +
                                                                      + +
                                                                      + Ton: freundlich & präzise + Detailgrad: hoch + Keine Übertreibungen +
                                                                      +
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      + +
                                                                      + Fotos fehlen + Lade mindestens ein Produktfoto hoch. +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Listing-Verwaltung

                                                                      +

                                                                      Artikel

                                                                      +

                                                                      Überblick über alle erstellten, geplanten und veröffentlichten Artikel.

                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + + + + + + +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      + + + + + + + +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      Auswahl
                                                                      +
                                                                      Artikel
                                                                      +
                                                                      Plattform
                                                                      +
                                                                      AI-Provider
                                                                      +
                                                                      Preis
                                                                      +
                                                                      Status
                                                                      +
                                                                      Lagerort
                                                                      +
                                                                      Veröffentlicht
                                                                      +
                                                                      Aktionen
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      + + + + + + +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Listing-Vorlagen

                                                                      + +
                                                                      +
                                                                      + +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Artikel Quality Center

                                                                      Qualitätscenter

                                                                      Prüfe Inhalte, Bilder, Pflichtfelder, mögliche Duplikate und Plattformtauglichkeit vor dem Veröffentlichen.

                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      DurchschnittQualitätsscore
                                                                      +
                                                                      Bereitohne Blocker
                                                                      +
                                                                      Blockiertharte Fehler
                                                                      +
                                                                      Artikelim Bestand
                                                                      +
                                                                      + +
                                                                      + + + + +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Prüfliste

                                                                      Artikelqualität

                                                                      0 Artikel
                                                                      +

                                                                      Qualitätsdaten werden geladen …

                                                                      +
                                                                      Seite 1 von 1
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Commerce Control

                                                                      +

                                                                      Publish-Center

                                                                      +

                                                                      Bereite Artikel für den Verkauf vor und veröffentliche sie sicher auf deinen Marktplätzen.

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      + + +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Papierkorb

                                                                      +
                                                                      + Einträge werden nach 30 Tagen automatisch gelöscht + +
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Lagerverwaltung

                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Verkäufererlös

                                                                      Gebührenrechner

                                                                      Aktuelle Orientierung für private Verkäufer in Deutschland. Etsy wird als Shop-Plattform separat kalkuliert; optionale Werbung, Zusatzleistungen, Steuern und internationale Sonderfälle sind nicht pauschal eingerechnet.

                                                                      + Stand 08.07.2026 +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Benutzer & Sicherheit

                                                                      + +
                                                                      + +
                                                                      + + + + + + + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      RBAC

                                                                      Rollen und Berechtigungen

                                                                      Verbindliche Rollenprofile für Artikel, Medien, Publishing und Administration.

                                                                      +
                                                                      +
                                                                      Rollen werden geladen …
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + Lade... +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + + E-Mail-Adresse, die als Absender angezeigt wird +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Runtime & Integrität

                                                                      +

                                                                      Systemprüfung

                                                                      +

                                                                      Prüft Datenbank, Schreibrechte, UI-Dateien, Browser-Extensions und die optionale ComfyUI-Verbindung – ohne API-Schlüssel offenzulegen.

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      Noch keine Systemprüfung ausgeführt.
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Sicherung, Updates & Browser

                                                                      Betriebszentrale

                                                                      Verifizierte Backups, sichere Wiederherstellung, Update-Staging und Extension-Diagnose an einem Ort.

                                                                      + +
                                                                      + + + +
                                                                      + + + +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Neues Backup

                                                                      Datenbank wird als konsistenter SQLite-Snapshot gesichert.

                                                                      + + + + +
                                                                      + +
                                                                      +

                                                                      Automatische Sicherung

                                                                      Ein tägliches rotierendes Backup, solange Vendoo läuft.

                                                                      + +
                                                                      + + +
                                                                      + + +
                                                                      + +
                                                                      +

                                                                      Wiederherstellung vorbereiten

                                                                      Das Backup wird vollständig geprüft. Vorher entsteht automatisch eine Sicherheitssicherung.

                                                                      + + +
                                                                      Keine Datei ausgewählt.
                                                                      + +
                                                                      +
                                                                      + +

                                                                      Vorhandene Backups

                                                                      Backups werden geladen …

                                                                      +
                                                                      Backups werden geladen …
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Installierter Stand

                                                                      Version, Migrationen und letzte Systemoperation.

                                                                      +
                                                                      Vendoo —Wird geladen …
                                                                      +
                                                                      +
                                                                      +
                                                                      +

                                                                      Online-Updateprüfung

                                                                      Optional über eine selbst konfigurierte öffentliche Manifest-URL.

                                                                      + +
                                                                      +
                                                                      Noch nicht geprüft.
                                                                      +
                                                                      +
                                                                      +

                                                                      Update-Paket vorbereiten

                                                                      Prüft Version, Pflichtdateien und verbotene Laufzeitdaten. Vorher wird automatisch gesichert.

                                                                      + + +
                                                                      Keine Datei ausgewählt.
                                                                      + +
                                                                      +
                                                                      +
                                                                      Bewusster Neustart statt riskanter Live-Aktualisierung

                                                                      Vorbereitete Updates und Wiederherstellungen werden erst bei beendetem Vendoo über Setup-Menüpunkt 11 angewendet. Menüpunkt 12 stellt den vorherigen Programmstand wieder her.

                                                                      +
                                                                      + +
                                                                      +
                                                                      Extension-Diagnose wird geladen …
                                                                      +
                                                                      +

                                                                      Zuletzt verbundene Browser

                                                                      Live gilt eine Verbindung, wenn sie sich in den letzten drei Minuten gemeldet hat.

                                                                      +
                                                                      Noch keine Extension-Verbindung gemeldet.
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Serverbetrieb

                                                                      Sicherheitszentrale

                                                                      Bind-Adresse, HTTPS, erlaubte Hosts, Sitzungen, Rate Limits und aktive Bearbeitungssperren.

                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +

                                                                      Netzwerk & HTTPS

                                                                      +

                                                                      Sessions & Limits

                                                                      +
                                                                      +

                                                                      Aktive Bearbeitungssperren

                                                                      Verhindert, dass zwei Benutzer denselben Artikel gleichzeitig überschreiben.

                                                                      +
                                                                      Keine aktiven Sperren.
                                                                      +
                                                                      +

                                                                      Empfohlene Server-Konfiguration

                                                                      +
                                                                      VENDOO_BIND_HOST=127.0.0.1
                                                                      +VENDOO_ALLOWED_HOSTS=vendoo.example.de
                                                                      +VENDOO_ALLOWED_ORIGINS=https://vendoo.example.de
                                                                      +VENDOO_TRUST_PROXY=true
                                                                      +PUBLIC_BASE_URL=https://vendoo.example.de
                                                                      +

                                                                      Für LAN-Zugriff VENDOO_BIND_HOST=0.0.0.0 verwenden. Öffentlichen Zugriff ausschließlich hinter einem HTTPS-Reverse-Proxy freigeben.

                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      + + + + + + + +
                                                                      +
                                                                      +
                                                                      +
                                                                      Seite 1
                                                                      +
                                                                      +
                                                                      + + + + + + + + + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +

                                                                      Lokale Bildgenerierung · Studio Pro

                                                                      +

                                                                      FLUX Studio Pro

                                                                      +

                                                                      Erzeuge 1, 2 oder 4 Prompt-Varianten, verwalte echte ComfyUI-Aufträge und greife jederzeit auf Verlauf, Parameter und Favoriten zurück.

                                                                      +
                                                                      +
                                                                      + Wird geprüft + + + + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      01

                                                                      Prompt & Auftrag

                                                                      Reiner Text-zu-Bild-Workflow
                                                                      + +
                                                                      +
                                                                      + + + + + +
                                                                      + +
                                                                      +
                                                                      + Prompt-Check

                                                                      Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.

                                                                      +
                                                                      + +
                                                                      +
                                                                      + Bildvarianten +
                                                                      + + + +
                                                                      + +
                                                                      + +
                                                                      + +
                                                                      + + + + +
                                                                      + +
                                                                      + + + + + +
                                                                      +
                                                                      + + Bereit. +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      02

                                                                      System & ComfyUI

                                                                      +
                                                                      Systemdaten werden geladen …
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      03

                                                                      Auswahl & Varianten

                                                                      Noch kein Bild
                                                                      + +
                                                                      +
                                                                      Dein FLUX-Bild erscheint hierPrompt eingeben und Auftrag starten.
                                                                      + +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Produktionszentrale

                                                                      FLUX Job-Center

                                                                      Aktive Aufträge, Verlauf und Archiv mit kontrollierter Bereinigung.

                                                                      +
                                                                      +
                                                                      + + +
                                                                      + + + + + + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + + + + +
                                                                      +
                                                                      +
                                                                      Aufträge werden geladen …
                                                                      +
                                                                      + + Seite 1 von 1 + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Bildbibliothek

                                                                      Ergebnisse & Favoriten

                                                                      +
                                                                      +
                                                                      +
                                                                      Noch keine FLUX-Bilder gespeichert.
                                                                      +
                                                                      Seite 1 von 1
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Zentrale Medienverwaltung

                                                                      Bildergalerie

                                                                      FLUX-, AI-, bearbeitete und in Artikeln verwendete Bilder gemeinsam durchsuchen, prüfen und verwalten.

                                                                      +
                                                                      + + +
                                                                      +
                                                                      + +
                                                                      + + + + + + +
                                                                      + +
                                                                      + + + + + +
                                                                      +
                                                                      + +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      0 Bilder
                                                                      +
                                                                      + + + von 1 + +
                                                                      +
                                                                      +
                                                                      Bildergalerie wird geladen …
                                                                      +
                                                                      +
                                                                      0 Bilder
                                                                      +
                                                                      + + + von 1 + +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +

                                                                      Batch Image Factory

                                                                      Bildproduktion

                                                                      Erzeuge aus mehreren Quellbildern automatisch optimierte Varianten für Marktplätze, Webshops und Social Media.

                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +

                                                                      Auftrag

                                                                      Quellen und Ausgabeprofile

                                                                      0 Bilder · 0 Profile
                                                                      + + +
                                                                      QuellbilderAus der zentralen Bildergalerie auswählen
                                                                      +
                                                                      Bilder werden geladen …
                                                                      + +
                                                                      ProduktionsprofileEin Auftrag kann mehrere Ausgaben pro Bild erzeugen
                                                                      +
                                                                      Profile werden geladen …
                                                                      + +
                                                                      + Zusätzliche Verarbeitung +
                                                                      + + + + + + +
                                                                      +
                                                                      +
                                                                      Noch keine Ausgabe geplantOriginalbilder bleiben unverändert. Jede Ausgabe wird als neue Version gespeichert.
                                                                      +
                                                                      + + +
                                                                      + +
                                                                      +

                                                                      Job-Center

                                                                      Produktionsaufträge

                                                                      +
                                                                      Noch keine Produktionsaufträge.
                                                                      +
                                                                      Seite 1 von 1
                                                                      +
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      +

                                                                      Browser-Integration

                                                                      Vendoo Link Extensions

                                                                      Bereite die passende Browser-Erweiterung vor, öffne den Installationsordner und folge der kurzen Anleitung. Der Browser verlangt aus Sicherheitsgründen immer eine abschließende Bestätigung.

                                                                      +
                                                                      +
                                                                      Aktiver BrowserWird erkannt …
                                                                      + +
                                                                      +
                                                                      +
                                                                      Sicher statt versteckt

                                                                      Vendoo installiert keine Erweiterung unbemerkt. Ein Klick bereitet alles automatisch vor; die letzte Freigabe erfolgt bewusst in Chrome, Edge, Firefox oder Safari.

                                                                      +
                                                                      +
                                                                      +
                                                                      So funktioniert die Ein-Klick-Vorbereitung

                                                                      Vendoo erkennt deinen Browser, öffnet den richtigen Erweiterungsordner und kopiert den Pfad. Danach musst du im Browser nur noch „Entpackte Erweiterung laden“ beziehungsweise die passende Firefox-/Safari-Aktion bestätigen.

                                                                      +
                                                                      1. Bei deinem Browser auf Installation vorbereiten klicken.
                                                                      2. Die geöffnete Erweiterungsverwaltung verwenden.
                                                                      3. Den bereits kopierten Ordner auswählen und Vendoo Link anheften.
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      + Systemzentrale +

                                                                      Einstellungen

                                                                      +

                                                                      Sauber getrennte Bereiche für Grundeinstellungen, AI-Dienste, Marktplätze und den lokalen FLUX-Bildserver.

                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      AI-SystemWird geprüft …
                                                                      +
                                                                      +
                                                                      + +
                                                                      Lokales FLUXWird geprüft …
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      +
                                                                      01

                                                                      Allgemeine Einstellungen

                                                                      Die Standards, mit denen Vendoo im Alltag startet.

                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      ArbeitsstandardVorauswahl im Generator
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      Netzwerk & SmartphoneQR-Upload im lokalen Netz
                                                                      +
                                                                      + +
                                                                      + Smartphone und Vendoo-PC müssen im selben Netzwerk sein. +
                                                                      Noch keine LAN-Adresse geprüft.
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      VerkäufernotizenAutomatischer Zusatz für neue Listing-Texte
                                                                      +
                                                                      Wird automatisch in jede Listing-Beschreibung eingebaut.
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      02

                                                                      Theme Manager

                                                                      Darstellung sicher über Design Tokens anpassen – ohne freies CSS und ohne Dateibearbeitung.

                                                                      + +
                                                                      +
                                                                      Meine DarstellungPersönliches Theme, Dichte und Bewegung
                                                                      Live-Vorschau
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      SystemstandardVorgabe für neue Benutzer und „Systemstandard“
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      Neues ThemeNur freigegebene Tokenwerte – kein CSS oder JavaScript
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      + +
                                                                      Theme Manager wird geladen …
                                                                      +
                                                                      + +
                                                                      +
                                                                      02

                                                                      AI & API-Dienste

                                                                      Textanalyse, Bildprovider und die Genauigkeit der Produktreferenz.

                                                                      + +
                                                                      +
                                                                      API-ZugängeKeys bleiben lokal in deiner Vendoo-Installation
                                                                      +
                                                                      +
                                                                      Claude für Listing-Texte.
                                                                      +
                                                                      Analyse, Moderation und Bild-Fallback.
                                                                      +
                                                                      Optionale kostenlose Bildmodelle.
                                                                      +
                                                                      +
                                                                      Lokale Text-AI via Ollama.
                                                                      +
                                                                      + + +
                                                                      +
                                                                      Lokale BildwerkzeugeGhost Mannequin, Flatlay und optionale Cloud-Fallbacks
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      768 ist deutlich schneller und für Marktplatzbilder meist ausreichend.
                                                                      +
                                                                      + +
                                                                      +
                                                                      Produktfotos verbindlich verwendenDiese Referenzsteuerung gilt für Ghost Mannequin und Flatlay. Der freie FLUX-Generator arbeitet unabhängig davon nur mit Texteingaben.
                                                                      + +
                                                                      70%
                                                                      +
                                                                      + +
                                                                      + + + + +
                                                                      + +
                                                                      + Cloud-Bildprovider konfigurierenOpenRouter und OpenAI +
                                                                      +
                                                                      Leer lassen für automatische Auswahl.
                                                                      +
                                                                      +
                                                                      Noch keine OpenRouter-Modelle geladen.
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      03

                                                                      Marktplätze

                                                                      API-Verbindungen für Etsy und eBay übersichtlich an einem Ort.

                                                                      +
                                                                      +
                                                                      +
                                                                      EtsyAPI-Key und Verbindung
                                                                      +
                                                                      Key unter etsy.com/developers erstellen.
                                                                      + +
                                                                      + +
                                                                      +
                                                                      eBay.deDeveloper-Zugang und OAuth
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      04

                                                                      Lokales FLUX / ComfyUI

                                                                      Installation, Aktivierung, Serverstatus und Wartung ohne Technik-Chaos.

                                                                      + +
                                                                      +
                                                                      +
                                                                      FLUX
                                                                      +
                                                                      Lokale Bild-Engine

                                                                      Status wird geprüft …

                                                                      Vendoo prüft Installation, Modell und laufenden Bildserver.

                                                                      +
                                                                      + +
                                                                      + + + + +
                                                                      +
                                                                      + +
                                                                      +
                                                                      InstallationWird geprüft
                                                                      +
                                                                      FLUX-ModellWird geprüft
                                                                      +
                                                                      BildserverWird geprüft
                                                                      +
                                                                      Prompt-ModusOhne Produktfoto
                                                                      +
                                                                      + +
                                                                      +
                                                                      Leistung & StandardausgabeOptimiert Startverhalten und neue FLUX-Aufträge.
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + + +
                                                                      HinweisDas Serverprofil wird beim nächsten FLUX-Start aktiv. Schnell hält Modelle bevorzugt im VRAM; Wenig VRAM spart Speicher, ist aber langsamer.
                                                                      +
                                                                      + +
                                                                      +
                                                                      Installation & WartungNur die Aktion wählen – Vendoo erledigt den Rest.
                                                                      Wird geprüft
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      + + + +
                                                                      +
                                                                      Lokaler FLUX/ComfyUI-Status wird geprüft.
                                                                      +
                                                                      + +
                                                                      +
                                                                      Technischer InstallationsstatusNoch nicht gestartet
                                                                      +
                                                                      Installation0%
                                                                      +
                                                                      InstallerBereit
                                                                      Laufzeit0 s
                                                                      Entpackt0 Dateien
                                                                      Datenmenge0 MB
                                                                      Heartbeat
                                                                      +
                                                                      + +
                                                                      Installationsprotokoll anzeigen
                                                                      Noch kein Installationsprotokoll vorhanden.
                                                                      +
                                                                      7-Zip-Ausgabe anzeigen
                                                                      Noch keine 7-Zip-Ausgabe vorhanden.
                                                                      +
                                                                      + +
                                                                      + Erweiterte lokale KonfigurationURL, Token und Pfade +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      +
                                                                      +
                                                                      +
                                                                      + +
                                                                      +
                                                                      Änderungen werden lokal gespeichertKonfigurationen gelten direkt nach dem Speichern.
                                                                      + +
                                                                      +
                                                                      +
                                                                      + + + + + + + + + + + + + + + + + + +
                                                                      + + + + + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..f8d6a93 --- /dev/null +++ b/public/login.html @@ -0,0 +1,263 @@ + + + + + + Vendoo — Login + + + + + + + + + + + + + + + + diff --git a/public/mobile-upload-expired.html b/public/mobile-upload-expired.html new file mode 100644 index 0000000..6ba98a9 --- /dev/null +++ b/public/mobile-upload-expired.html @@ -0,0 +1,9 @@ +Upload-Link abgelaufen + + + + + + + +
                                                                      VendooMobiler Foto-Upload
                                                                      !

                                                                      Upload-Link abgelaufen

                                                                      Erzeuge in Vendoo einen neuen QR-Code und scanne ihn erneut.

                                                                      diff --git a/public/mobile-upload.css b/public/mobile-upload.css new file mode 100644 index 0000000..6996f00 --- /dev/null +++ b/public/mobile-upload.css @@ -0,0 +1,65 @@ +:root{--bg:#f4f0e8;--surface:#fffdf9;--surface-2:#f9f6f0;--line:#ded7cc;--ink:#24221e;--muted:#777168;--brand:#e63b17;--brand-dark:#c92e0d;--brand-soft:rgba(230,59,23,.09);--success:#218356;--success-soft:rgba(33,131,86,.1);--danger:#be4037;--shadow:0 24px 60px rgba(44,38,30,.12)}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;min-height:100dvh;background:radial-gradient(circle at 100% 0,rgba(230,59,23,.07),transparent 35%),var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.mobile-upload-shell{width:min(100%,540px);min-height:100dvh;margin:auto;padding:calc(18px + env(safe-area-inset-top)) 16px calc(24px + env(safe-area-inset-bottom));display:grid;align-content:start;gap:14px}.mobile-upload-brand{display:flex;align-items:center;gap:11px;padding:2px 3px 7px}.mobile-upload-brand>div{display:grid;gap:1px}.mobile-upload-brand strong{font-size:19px;letter-spacing:-.04em}.mobile-upload-brand span:not(.mobile-upload-logo){font-size:11px;color:var(--muted)}.mobile-upload-logo{position:relative;width:38px;height:38px;display:block}.mobile-upload-logo i{position:absolute;left:5px;top:5px;width:9px;height:28px;border-radius:6px;background:var(--brand);transform:rotate(-30deg)}.mobile-upload-logo b{position:absolute;left:19px;top:4px;width:15px;height:17px;border-radius:4px 7px 7px 5px;background:var(--brand);transform:rotate(14deg)}.mobile-upload-logo b:after{content:"";position:absolute;width:4px;height:4px;right:3px;top:3px;border-radius:50%;background:#fff}.mobile-upload-logo em{position:absolute;right:0;bottom:0;width:15px;height:15px;display:grid;place-items:center;border-radius:50%;background:#30302d;color:#fff;font-size:9px;font-style:normal}.mobile-upload-card{padding:20px;border:1px solid var(--line);border-radius:20px;background:rgba(255,253,249,.94);box-shadow:0 10px 35px rgba(44,38,30,.06);backdrop-filter:blur(12px)}.mobile-upload-card.hero{padding:24px}.eyebrow{display:block;margin-bottom:8px;color:var(--brand);font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.mobile-upload-card h1,.mobile-upload-card h2{margin:0;color:var(--ink);font-size:clamp(25px,7vw,34px);line-height:1.04;letter-spacing:-.05em}.mobile-upload-card h2{font-size:24px}.mobile-upload-card p{margin:11px 0 0;color:var(--muted);font-size:14px;line-height:1.55}.session-status{display:flex;align-items:center;gap:8px;margin-top:18px;padding:10px 12px;border-radius:11px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:700}.session-status span{width:8px;height:8px;border-radius:50%;background:#aaa}.session-status.ok{background:var(--success-soft);color:var(--success)}.session-status.ok span{background:var(--success)}.camera-action{min-height:180px;display:grid;place-items:center;align-content:center;gap:7px;padding:20px;border:1.5px dashed #cfc6b8;border-radius:16px;background:linear-gradient(145deg,#fff,var(--surface-2));text-align:center;cursor:pointer}.camera-action:active{border-color:var(--brand);background:var(--brand-soft)}.camera-icon{width:48px;height:48px;display:grid;place-items:center;border-radius:15px;background:var(--brand);color:#fff;font-size:28px;box-shadow:0 12px 25px rgba(230,59,23,.2)}.camera-action strong{font-size:15px}.camera-action small{color:var(--muted);font-size:11px}.upload-meta{display:flex;justify-content:space-between;gap:12px;margin:14px 2px 9px;color:var(--muted);font-size:11px}.mobile-preview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.mobile-preview figure{position:relative;aspect-ratio:1;margin:0;overflow:hidden;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}.mobile-preview img{width:100%;height:100%;object-fit:contain}.mobile-preview button{position:absolute;right:5px;top:5px;width:25px;height:25px;border:0;border-radius:50%;background:rgba(28,27,24,.8);color:#fff;font-size:15px}.mobile-primary,.mobile-secondary{width:100%;min-height:50px;margin-top:14px;border-radius:13px;font:inherit;font-size:14px;font-weight:800;cursor:pointer}.mobile-primary{border:0;background:var(--brand);color:#fff;box-shadow:0 14px 28px rgba(230,59,23,.2)}.mobile-primary:disabled{opacity:.4;box-shadow:none}.mobile-primary:not(:disabled):active{background:var(--brand-dark)}.mobile-secondary{border:1px solid var(--line);background:var(--surface);color:var(--ink)}.upload-progress{height:7px;margin-top:13px;overflow:hidden;border-radius:999px;background:#e7e1d8}.upload-progress span{display:block;width:45%;height:100%;border-radius:inherit;background:var(--brand);animation:progress 1.1s ease-in-out infinite alternate}@keyframes progress{to{transform:translateX(122%)}}.success,.error{text-align:center}.success-mark,.error-mark{width:62px;height:62px;display:grid;place-items:center;margin:0 auto 15px;border-radius:20px;font-size:31px;font-weight:900}.success-mark{background:var(--success-soft);color:var(--success)}.error-mark{background:rgba(190,64,55,.1);color:var(--danger)}footer{padding:4px 18px;color:var(--muted);font-size:10px;line-height:1.5;text-align:center}.hidden{display:none!important}@media(min-width:460px){.mobile-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:360px){.mobile-upload-shell{padding-inline:10px}.mobile-upload-card{padding:16px}.mobile-upload-card.hero{padding:19px}.mobile-preview{grid-template-columns:repeat(2,minmax(0,1fr))}} +.mobile-upload-brand-icon{width:40px;height:40px;display:block;object-fit:contain;flex:0 0 auto} + +.mobile-source-actions, +.mobile-more-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.camera-action { + width: 100%; + min-height: 132px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--brand-soft); + color: var(--ink); + padding: 18px 14px; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + text-align: center; + cursor: pointer; + font: inherit; +} + +.camera-action.secondary-source { + background: var(--surface-2); +} + +.camera-action:disabled, +.mobile-secondary:disabled { + opacity: .5; + cursor: not-allowed; +} + +.mobile-preview-fallback { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-content: center; + gap: 4px; + padding: 8px; + text-align: center; + background: var(--surface-2); + color: var(--muted); + font-size: 11px; +} + +.mobile-preview-fallback strong { + color: var(--ink); + font-size: 12px; +} + +@media (max-width: 420px) { + .mobile-source-actions, + .mobile-more-actions { + grid-template-columns: 1fr; + } + + .camera-action { + min-height: 112px; + } +} diff --git a/public/mobile-upload.html b/public/mobile-upload.html new file mode 100644 index 0000000..54152a5 --- /dev/null +++ b/public/mobile-upload.html @@ -0,0 +1,77 @@ + + + + + + + Vendoo Foto-Upload + + + + + + + + + + + +
                                                                      +
                                                                      + +
                                                                      VendooMobiler Foto-Upload
                                                                      +
                                                                      + +
                                                                      + Direkt vom Smartphone +

                                                                      Produktfotos aufnehmen

                                                                      +

                                                                      Fotografiere den Artikel oder wähle vorhandene Bilder. Sie erscheinen automatisch im geöffneten Vendoo-Studio.

                                                                      +
                                                                      Upload-Link wird geprüft …
                                                                      +
                                                                      + + + + + + + +
                                                                      Die Bilder werden ausschließlich an deine Vendoo-Instanz übertragen.
                                                                      +
                                                                      + + + diff --git a/public/mobile-upload.js b/public/mobile-upload.js new file mode 100644 index 0000000..1ad5b2b --- /dev/null +++ b/public/mobile-upload.js @@ -0,0 +1,169 @@ +(() => { + 'use strict'; + + const token = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || ''); + const cameraInput = document.getElementById('camera-input'); + const libraryInput = document.getElementById('library-input'); + const preview = document.getElementById('mobile-preview'); + const uploadButton = document.getElementById('upload-btn'); + const uploadPanel = document.getElementById('upload-panel'); + const successPanel = document.getElementById('success-panel'); + const errorPanel = document.getElementById('error-panel'); + const status = document.getElementById('session-status'); + const count = document.getElementById('photo-count'); + const expiresCopy = document.getElementById('expires-copy'); + const progress = document.getElementById('upload-progress'); + const sourceButtons = [ + document.getElementById('open-camera-btn'), + document.getElementById('open-library-btn'), + document.getElementById('add-camera-btn'), + document.getElementById('add-library-btn'), + document.getElementById('success-camera-btn'), + document.getElementById('success-library-btn'), + ].filter(Boolean); + + let selected = []; + let serverFiles = []; + let remaining = 10; + + function showError(message) { + uploadPanel.classList.add('hidden'); + successPanel.classList.add('hidden'); + errorPanel.classList.remove('hidden'); + document.getElementById('error-copy').textContent = message; + status.className = 'session-status'; + status.innerHTML = 'Upload nicht verfügbar'; + } + + function openCamera() { + if (remaining - selected.length <= 0) return; + cameraInput.click(); + } + + function openLibrary() { + if (remaining - selected.length <= 0) return; + libraryInput.click(); + } + + function render() { + preview.innerHTML = ''; + selected.forEach((file, index) => { + const figure = document.createElement('figure'); + const image = document.createElement('img'); + image.alt = file.name || `Foto ${index + 1}`; + const objectUrl = URL.createObjectURL(file); + image.src = objectUrl; + image.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true }); + image.addEventListener('error', () => { + URL.revokeObjectURL(objectUrl); + image.remove(); + const fallback = document.createElement('div'); + fallback.className = 'mobile-preview-fallback'; + fallback.innerHTML = `Bild ${index + 1}${file.name || 'Foto ausgewählt'}`; + figure.prepend(fallback); + }, { once: true }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.textContent = '×'; + remove.setAttribute('aria-label', `${file.name || 'Bild'} entfernen`); + remove.addEventListener('click', () => { + selected.splice(index, 1); + render(); + }); + figure.append(image, remove); + preview.appendChild(figure); + }); + + const total = serverFiles.length + selected.length; + count.textContent = `${total} von 10 Bildern`; + uploadButton.disabled = selected.length === 0; + uploadButton.textContent = selected.length + ? `${selected.length} ${selected.length === 1 ? 'Foto' : 'Fotos'} zu Vendoo senden` + : 'Fotos zu Vendoo senden'; + + const noSlots = remaining - selected.length <= 0; + sourceButtons.forEach(button => { button.disabled = noSlots; }); + } + + function addFiles(files) { + const candidates = [...files].filter(file => file.type.startsWith('image/') || /\.(heic|heif)$/i.test(file.name || '')); + const slots = Math.max(0, remaining - selected.length); + selected.push(...candidates.slice(0, slots)); + render(); + } + + async function loadSession() { + try { + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}`, { cache: 'no-store' }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload-Link konnte nicht geladen werden.'); + serverFiles = data.files || []; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + status.className = 'session-status ok'; + status.innerHTML = 'Mit Vendoo verbunden'; + uploadPanel.classList.remove('hidden'); + const expires = new Date(String(data.expires_at).replace(' ', 'T') + (String(data.expires_at).includes('Z') ? '' : 'Z')); + expiresCopy.textContent = Number.isNaN(expires.getTime()) + ? '' + : `gültig bis ${expires.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + render(); + } catch (error) { + showError(error.message); + } + } + + async function upload() { + if (!selected.length) return; + uploadButton.disabled = true; + sourceButtons.forEach(button => { button.disabled = true; }); + progress.classList.remove('hidden'); + try { + const form = new FormData(); + selected.forEach(file => form.append('photos', file, file.name || `mobile-${Date.now()}.jpg`)); + const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}/photos?platform=mobile`, { + method: 'POST', + body: form, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen.'); + serverFiles = data.files || serverFiles; + remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length); + selected = []; + render(); + uploadPanel.classList.add('hidden'); + successPanel.classList.remove('hidden'); + } catch (error) { + showError(error.message); + } finally { + progress.classList.add('hidden'); + uploadButton.disabled = false; + sourceButtons.forEach(button => { button.disabled = false; }); + } + } + + function returnToUploadAndOpen(opener) { + successPanel.classList.add('hidden'); + uploadPanel.classList.remove('hidden'); + window.setTimeout(opener, 50); + } + + cameraInput.addEventListener('change', () => { + addFiles(cameraInput.files || []); + cameraInput.value = ''; + }); + libraryInput.addEventListener('change', () => { + addFiles(libraryInput.files || []); + libraryInput.value = ''; + }); + + document.getElementById('open-camera-btn').addEventListener('click', openCamera); + document.getElementById('open-library-btn').addEventListener('click', openLibrary); + document.getElementById('add-camera-btn').addEventListener('click', openCamera); + document.getElementById('add-library-btn').addEventListener('click', openLibrary); + document.getElementById('success-camera-btn').addEventListener('click', () => returnToUploadAndOpen(openCamera)); + document.getElementById('success-library-btn').addEventListener('click', () => returnToUploadAndOpen(openLibrary)); + uploadButton.addEventListener('click', upload); + + loadSession(); +})(); diff --git a/public/modules/README.md b/public/modules/README.md new file mode 100644 index 0000000..5f503fd --- /dev/null +++ b/public/modules/README.md @@ -0,0 +1,9 @@ +# Frontend-Module + +Neue Frontend-Module werden künftig als ES-Module unter diesem Verzeichnis angelegt und über `FrontendModuleRegistry` mit einem klaren `mount`-/`unmount`-Lebenszyklus betrieben. + +Die Bestandsoberfläche aus `public/app.js` bleibt in 1.35.0 unverändert aktiv. Sie wird in späteren Releases tabweise migriert. Direkte neue globale Event-Listener und ungeprüfte `innerHTML`-Zuweisungen sind für migrierte Module nicht zulässig. + +## Theme Manager 1.36.0 + +`theme-manager/` ist das erste sichtbare native Frontend-Fachmodul. Es nutzt den zentralen API-Client, sichere DOM-Erzeugung, Token-Verträge und einen klaren Vorschau-/Speicher-Lebenszyklus. diff --git a/public/modules/theme-manager/theme-manager.css b/public/modules/theme-manager/theme-manager.css new file mode 100644 index 0000000..7ee5c3d --- /dev/null +++ b/public/modules/theme-manager/theme-manager.css @@ -0,0 +1,49 @@ +:root { + --vd-theme-manager-preview-height: 210px; +} +[data-reduced-motion="true"] *, [data-reduced-motion="true"] *::before, [data-reduced-motion="true"] *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; } +[data-density="compact"] { --vd-control-height: 34px; --vd-content-gap: 10px; --vd-component-button-height: 34px; --vd-component-input-height: 36px; --vd-component-card-padding: 12px; } +[data-density="spacious"] { --vd-control-height: 46px; --vd-content-gap: 22px; --vd-component-button-height: 46px; --vd-component-input-height: 48px; --vd-component-card-padding: 22px; } +.vd-card { background: var(--vd-surface-raised); border: 1px solid var(--vd-line); border-radius: var(--vd-component-card-radius); padding: var(--vd-component-card-padding); box-shadow: var(--vd-shadow-soft); } +.vd-button { min-height: var(--vd-component-button-height); border-radius: var(--vd-component-button-radius); } +.setting-group input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), .setting-group select, .vd-input { min-height: var(--vd-component-input-height); border-radius: var(--vd-component-input-radius); } +:where(button, a, input, select, textarea, [tabindex]):focus-visible { outline: var(--vd-component-focus-width) solid var(--vd-brand); outline-offset: 2px; } +.theme-manager-grid { display:grid; gap:16px; } +.theme-choice-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:10px; } +.theme-choice { min-height:104px; display:grid; gap:8px; align-content:space-between; padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); color:var(--vd-ink); text-align:left; cursor:pointer; } +.theme-choice:hover { border-color:var(--vd-brand); } +.theme-choice[aria-pressed="true"] { border-color:var(--vd-brand); box-shadow:0 0 0 3px var(--vd-focus); } +.theme-choice-swatches { display:flex; gap:5px; } +.theme-choice-swatches i { width:22px; height:22px; border-radius:50%; border:1px solid color-mix(in srgb, currentColor 18%, transparent); } +.theme-manager-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:end; } +.theme-manager-toolbar .setting-group { min-width:170px; flex:1 1 170px; } +.theme-editor-layout { display:grid; grid-template-columns:minmax(0,1.3fr) minmax(260px,.7fr); gap:14px; } +.theme-token-groups { display:grid; gap:12px; max-height:620px; overflow:auto; padding-right:4px; } +.theme-token-group { border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); overflow:hidden; } +.theme-token-group > h4 { margin:0; padding:10px 12px; background:var(--vd-surface-muted); font-size:12px; } +.theme-token-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:10px; padding:12px; } +.theme-token-field { display:grid; gap:5px; } +.theme-token-field label { font-size:11px; font-weight:700; } +.theme-color-control { display:grid; grid-template-columns:42px minmax(0,1fr); gap:7px; } +.theme-color-control input[type="color"] { width:42px; height:var(--vd-component-input-height); padding:3px; border:1px solid var(--vd-line); border-radius:var(--vd-component-input-radius); background:var(--vd-surface); } +.theme-preview { position:sticky; top:10px; display:grid; gap:12px; align-content:start; } +.theme-preview-stage { min-height:var(--vd-theme-manager-preview-height); display:grid; gap:10px; padding:16px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-canvas); color:var(--vd-ink); } +.theme-preview-card { padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); } +.theme-preview-actions { display:flex; gap:8px; flex-wrap:wrap; } +.theme-contrast-list { display:grid; gap:6px; } +.theme-contrast-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; padding:7px 9px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); font-size:11px; } +.theme-contrast-row.is-ok strong { color:var(--vd-success); } +.theme-contrast-row.is-fail strong { color:var(--vd-danger); } +.theme-manager-status { min-height:22px; padding:8px 10px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:11px; } +.theme-profile-list { display:grid; gap:8px; } +.theme-profile-row { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:10px; border:1px solid var(--vd-line); border-radius:var(--vd-radius-sm); } +.theme-profile-row > div { min-width:0; } +.theme-profile-row small { color:var(--vd-ink-muted); } +.theme-admin-only.hidden { display:none !important; } +@media(max-width:900px){ .theme-editor-layout{grid-template-columns:1fr}.theme-preview{position:static}.theme-token-groups{max-height:none} } +@media(min-width:901px){ .settings-nav { grid-template-columns:repeat(5,minmax(0,1fr)); } } +.theme-manager-status[data-tone="success"] { color:var(--vd-success); background:var(--vd-success-soft); } +.theme-manager-status[data-tone="warning"] { color:var(--vd-warning); background:var(--vd-warning-soft); } +.theme-manager-status[data-tone="danger"] { color:var(--vd-danger); background:var(--vd-danger-soft); } +.theme-choice > span:last-child { display:grid; gap:2px; } +.theme-choice small { color:var(--vd-ink-muted); } diff --git a/public/modules/theme-manager/theme-manager.js b/public/modules/theme-manager/theme-manager.js new file mode 100644 index 0000000..ce6f256 --- /dev/null +++ b/public/modules/theme-manager/theme-manager.js @@ -0,0 +1,328 @@ +import { apiRequest } from '../../core/api-client.js'; + +const root = document.getElementById('theme-manager-root'); +if (!root) throw new Error('Theme Manager Root fehlt.'); + +const state = { + catalog: null, + me: null, + selectedThemeId: 'inherit', + editingId: null, + draftId: null, + editorValues: {}, +}; + +const $ = selector => root.querySelector(selector); +const status = (message, tone = 'info') => { + const node = $('#theme-manager-status'); + node.textContent = message; + node.dataset.tone = tone; +}; + +function cssValues(values = {}) { + const tokens = state.catalog?.contract?.tokens || {}; + return Object.fromEntries(Object.entries(values).flatMap(([name, value]) => tokens[name]?.css ? [[tokens[name].css, value]] : [])); +} + +function profileById(id) { + return [...(state.catalog?.builtin || []), ...(state.catalog?.custom || [])].find(theme => theme.id === id) || null; +} + +function runtimeState(preference, profile = null) { + return { + theme_id: preference.theme_id, + density: preference.density, + reduced_motion: preference.reduced_motion, + custom_theme: profile && !profile.builtin ? { + id: profile.id, + base_theme: profile.base_theme, + css_values: cssValues(profile.resolved_values || profile.values), + } : null, + }; +} + +function applyPreference(preference, { persist = true } = {}) { + const effective = preference.theme_id === 'inherit' ? state.catalog.system_default : preference; + const profile = profileById(effective.theme_id); + window.VendooTheme?.applyState(runtimeState(effective, profile), { persist }); +} + + +function renderChoices() { + const host = $('#theme-choice-grid'); + const choices = [ + { id: 'inherit', name: 'Systemstandard', base_theme: state.catalog.system_default.theme_id, resolved_values: profileById(state.catalog.system_default.theme_id)?.resolved_values || profileById('light').resolved_values, builtin: true }, + { id: 'system', name: 'Betriebssystem', base_theme: 'system', resolved_values: profileById(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light').resolved_values, builtin: true }, + ...state.catalog.builtin.filter(item => item.id !== 'system'), + ...state.catalog.custom, + ]; + host.replaceChildren(...choices.map(profile => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'theme-choice'; + button.dataset.themeId = profile.id; + button.setAttribute('aria-pressed', String(profile.id === state.selectedThemeId)); + const swatchWrap = document.createElement('span'); + swatchWrap.className = 'theme-choice-swatches'; + swatchWrap.setAttribute('aria-hidden', 'true'); + for (const token of ['color.canvas', 'color.surface', 'color.brand', 'color.ink']) { + const swatch = document.createElement('i'); + swatch.style.background = profile.resolved_values?.[token] || '#888888'; + swatchWrap.append(swatch); + } + const copy = document.createElement('span'); + const title = document.createElement('strong'); title.textContent = profile.name; + const meta = document.createElement('small'); meta.textContent = profile.builtin ? 'Vendoo Preset' : 'Eigenes Theme'; + copy.append(title, meta); button.append(swatchWrap, copy); + button.addEventListener('click', () => selectTheme(profile.id)); + return button; + })); +} + +function selectTheme(themeId) { + state.selectedThemeId = themeId; + root.querySelectorAll('.theme-choice').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.themeId === themeId))); + const preference = { + theme_id: themeId, + density: $('#theme-density').value, + reduced_motion: $('#theme-reduced-motion').value, + }; + applyPreference(preference, { persist: false }); + status('Vorschau aktiv. Mit „Meine Darstellung speichern“ dauerhaft übernehmen.'); +} + + +function renderSystemOptions() { + const select = $('#theme-system-default'); + const selected = state.catalog.system_default.theme_id; + [...select.querySelectorAll('option[data-custom="true"]')].forEach(option => option.remove()); + for (const profile of state.catalog.custom) { + const option = document.createElement('option'); + option.value = profile.id; + option.textContent = profile.name; + option.dataset.custom = 'true'; + select.append(option); + } + select.value = selected; +} + +function renderProfiles() { + const list = $('#theme-profile-list'); + list.replaceChildren(...state.catalog.custom.map(profile => { + const row = document.createElement('div'); + row.className = 'theme-profile-row'; + const copy = document.createElement('div'); + const title = document.createElement('strong'); + title.textContent = profile.name; + const meta = document.createElement('small'); + meta.textContent = `Basis: ${profile.base_theme} · ${Object.keys(profile.values || {}).length} Anpassungen`; + copy.append(title, document.createElement('br'), meta); + const actions = document.createElement('div'); + const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'vd-button compact'; edit.textContent = 'Bearbeiten'; edit.addEventListener('click', () => openEditor(profile)); + const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'vd-button compact'; exp.textContent = 'Export'; exp.addEventListener('click', () => exportProfile(profile.id)); + actions.append(edit, exp); row.append(copy, actions); return row; + })); + if (!state.catalog.custom.length) { + const empty = document.createElement('p'); empty.className = 'theme-manager-status'; empty.textContent = 'Noch keine eigenen Themes angelegt.'; list.append(empty); + } +} + +function baseValues(baseTheme) { + return profileById(baseTheme)?.resolved_values || profileById('light')?.resolved_values || {}; +} + +function currentEditorDefinition() { + return { + id: state.editingId || state.draftId || 'custom-draft', + name: $('#theme-editor-name').value.trim(), + base_theme: $('#theme-editor-base').value, + values: { ...state.editorValues }, + }; +} + +function renderTokenFields(profile = null) { + const baseTheme = $('#theme-editor-base').value; + const defaults = baseValues(baseTheme); + const contract = state.catalog.contract.tokens; + const groups = new Map(); + for (const [name, token] of Object.entries(contract)) { + if (!token.customizable) continue; + if (!groups.has(token.group)) groups.set(token.group, []); + groups.get(token.group).push([name, token]); + } + const host = $('#theme-token-groups'); + host.replaceChildren(...[...groups.entries()].map(([groupName, entries]) => { + const section = document.createElement('section'); section.className = 'theme-token-group'; + const heading = document.createElement('h4'); heading.textContent = groupName; section.append(heading); + const list = document.createElement('div'); list.className = 'theme-token-list'; + for (const [name, token] of entries) { + const field = document.createElement('div'); field.className = 'theme-token-field'; + const label = document.createElement('label'); label.htmlFor = `theme-token-${name.replace(/[^a-z0-9]/gi, '-')}`; label.textContent = token.label; + const value = state.editorValues[name] ?? defaults[name] ?? ''; + if (token.type === 'color') { + const wrap = document.createElement('div'); wrap.className = 'theme-color-control'; + const color = document.createElement('input'); color.type = 'color'; color.value = /^#[0-9a-f]{6}$/i.test(value) ? value : '#000000'; color.tabIndex = -1; color.setAttribute('aria-hidden', 'true'); + const text = document.createElement('input'); text.id = label.htmlFor; text.className = 'vd-input'; text.value = value; text.dataset.token = name; + color.addEventListener('input', () => { text.value = color.value; updateEditorValue(name, text.value); }); + text.addEventListener('input', () => { if (/^#[0-9a-f]{6}$/i.test(text.value)) color.value = text.value; updateEditorValue(name, text.value); }); + wrap.append(color, text); field.append(label, wrap); + } else { + const input = document.createElement('input'); input.id = label.htmlFor; input.className = 'vd-input'; input.value = value; input.dataset.token = name; input.inputMode = 'decimal'; + input.addEventListener('input', () => updateEditorValue(name, input.value)); field.append(label, input); + } + list.append(field); + } + section.append(list); return section; + })); + previewEditor(); +} + +function updateEditorValue(name, value) { + const base = baseValues($('#theme-editor-base').value)[name]; + if (String(value).trim() === String(base).trim()) delete state.editorValues[name]; + else state.editorValues[name] = String(value).trim(); + previewEditor(); +} + +function previewEditor() { + const definition = currentEditorDefinition(); + const merged = { ...baseValues(definition.base_theme), ...definition.values }; + window.VendooTheme?.previewTheme({ base_theme: definition.base_theme, css_values: cssValues(merged), density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value }); + $('#theme-preview-name').textContent = definition.name || 'Theme-Vorschau'; +} + + +async function validateEditor(showMessage = true) { + try { + const result = await apiRequest('/api/themes/validate', { method: 'POST', body: currentEditorDefinition() }); + renderContrast(result.accessibility); + if (showMessage) status(result.accessibility.ok ? 'Theme ist gültig und erfüllt die geprüften Kontrastziele.' : 'Theme ist gültig, hat aber Kontrastwarnungen.', result.accessibility.ok ? 'success' : 'warning'); + return result; + } catch (error) { + renderContrast({ checks: [], ok: false }); + if (showMessage) status(error.message, 'danger'); + return null; + } +} + +function renderContrast(accessibility) { + const host = $('#theme-contrast-list'); + host.replaceChildren(...(accessibility?.checks || []).map(check => { + const row = document.createElement('div'); row.className = `theme-contrast-row ${check.ok ? 'is-ok' : 'is-fail'}`; + const label = document.createElement('span'); label.textContent = check.id.replaceAll('-', ' '); + const result = document.createElement('strong'); result.textContent = `${check.ratio ?? '–'} : 1`; + row.append(label, result); return row; + })); +} + +function openEditor(profile = null) { + state.editingId = profile?.id || null; + state.draftId = profile?.id || `custom-${Date.now().toString(36)}`; + state.editorValues = { ...(profile?.values || {}) }; + $('#theme-editor-name').value = profile?.name || ''; + $('#theme-editor-base').value = profile?.base_theme || 'light'; + $('#theme-delete-btn').hidden = !profile; + $('#theme-editor-title').textContent = profile ? 'Theme bearbeiten' : 'Neues Theme'; + renderTokenFields(profile); + $('#theme-editor-name').focus(); +} + +async function savePreference() { + const payload = { theme_id: state.selectedThemeId, density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value }; + try { + const saved = await apiRequest('/api/themes/preference', { method: 'PUT', body: payload }); + state.catalog.preference = saved; + applyPreference(saved, { persist: true }); + status('Deine Darstellung wurde sicher gespeichert.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function saveProfile() { + const valid = await validateEditor(true); if (!valid) return; + const definition = currentEditorDefinition(); + try { + const profile = state.editingId + ? await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'PUT', body: definition }) + : await apiRequest('/api/themes', { method: 'POST', body: definition }); + await reloadCatalog(); + renderSystemOptions(); + state.editingId = profile.id; + state.selectedThemeId = profile.id; + renderChoices(); renderProfiles(); openEditor(profile); + status('Theme gespeichert.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function deleteProfile() { + if (!state.editingId || !confirm('Dieses Theme wirklich löschen? Benutzer mit diesem Theme fallen auf den Systemstandard zurück.')) return; + try { + await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'DELETE' }); + await reloadCatalog(); renderSystemOptions(); state.selectedThemeId = state.catalog.preference.theme_id; renderChoices(); renderProfiles(); openEditor(); + status('Theme gelöscht.', 'success'); + } catch (error) { status(error.message, 'danger'); } +} + +async function saveSystemDefault() { + const payload = { theme_id: $('#theme-system-default').value, density: $('#theme-system-density').value, reduced_motion: $('#theme-system-motion').value }; + try { state.catalog.system_default = await apiRequest('/api/themes/system-default', { method: 'PUT', body: payload }); status('Systemstandard gespeichert.', 'success'); renderChoices(); } + catch (error) { status(error.message, 'danger'); } +} + +async function exportProfile(id) { + try { + const payload = await apiRequest(`/api/themes/${encodeURIComponent(id)}/export`); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${id}.vendoo-theme.json`; link.click(); URL.revokeObjectURL(link.href); + } catch (error) { status(error.message, 'danger'); } +} + +function importProfile(file) { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const payload = JSON.parse(String(reader.result)); + const profile = payload.theme || payload; + state.editingId = null; state.draftId = `custom-${Date.now().toString(36)}`; state.editorValues = { ...(profile.values || {}) }; + $('#theme-editor-name').value = `${profile.name || 'Importiertes Theme'} Kopie`; + $('#theme-editor-base').value = profile.base_theme || 'light'; renderTokenFields(); status('Import geladen. Prüfen und anschließend speichern.'); + } catch { status('Die Theme-Datei ist ungültig.', 'danger'); } + }; + reader.readAsText(file); +} + +async function reloadCatalog() { + state.catalog = await apiRequest('/api/themes'); +} + +async function init() { + try { + [state.catalog, state.me] = await Promise.all([apiRequest('/api/themes'), apiRequest('/api/me')]); + state.selectedThemeId = state.catalog.preference.theme_id; + $('#theme-density').value = state.catalog.preference.density; + $('#theme-reduced-motion').value = state.catalog.preference.reduced_motion; + $('#theme-system-default').value = state.catalog.system_default.theme_id; + $('#theme-system-density').value = state.catalog.system_default.density; + $('#theme-system-motion').value = state.catalog.system_default.reduced_motion; + renderSystemOptions(); + const canManage = state.me.permissions?.includes('settings.manage'); + root.querySelectorAll('.theme-admin-only').forEach(node => node.classList.toggle('hidden', !canManage)); + renderChoices(); renderProfiles(); openEditor(); applyPreference(state.catalog.effective_preference, { persist: true }); + status('Theme Manager bereit.'); + } catch (error) { status(`Theme Manager konnte nicht geladen werden: ${error.message}`, 'danger'); } +} + +$('#theme-preference-save').addEventListener('click', savePreference); +$('#theme-density').addEventListener('change', () => selectTheme(state.selectedThemeId)); +$('#theme-reduced-motion').addEventListener('change', () => selectTheme(state.selectedThemeId)); +$('#theme-new-btn').addEventListener('click', () => openEditor()); +$('#theme-editor-base').addEventListener('change', () => { state.editorValues = {}; renderTokenFields(); }); +$('#theme-editor-name').addEventListener('input', previewEditor); +$('#theme-validate-btn').addEventListener('click', () => validateEditor(true)); +$('#theme-save-btn').addEventListener('click', saveProfile); +$('#theme-delete-btn').addEventListener('click', deleteProfile); +$('#theme-preview-reset').addEventListener('click', () => applyPreference(state.catalog.preference, { persist: false })); +$('#theme-system-save').addEventListener('click', saveSystemDefault); +$('#theme-import-input').addEventListener('change', event => importProfile(event.target.files?.[0])); + +init(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..536bb6f --- /dev/null +++ b/public/style.css @@ -0,0 +1,7377 @@ +@import url('./design-system/tokens.css?v=1.36.0'); + +:root { + color-scheme: light; + /* Kompatibilitätsbrücke: bestehende Oberfläche bleibt unverändert, neue Module verwenden --vd-* direkt. */ + --bg: var(--vd-canvas); + --bg-card: var(--vd-surface-raised); + --bg-input: var(--vd-surface-raised); + --bg-hover: var(--vd-surface-muted); + --bg-page: var(--vd-surface-quiet); + --border: var(--vd-line); + --border-focus: var(--vd-brand); + --text: var(--vd-ink); + --text-muted: var(--vd-ink-muted); + --text-secondary: var(--vd-ink-secondary); + --accent: var(--vd-brand); + --accent-hover: var(--vd-brand-strong); + --accent-light: var(--vd-brand-soft); + --orange: var(--vd-warning); + --orange-hover: var(--vd-warning); + --orange-light: var(--vd-warning-soft); + --success: var(--vd-success); + --danger: var(--vd-danger); + --warning: var(--vd-warning); + --radius: var(--vd-radius-md); + --radius-sm: var(--vd-radius-sm); + --shadow-sm: none; + --shadow: var(--vd-shadow-soft); +} + +[data-theme="dark"] { color-scheme: dark; } +[data-theme="contrast"] { color-scheme: dark; } + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; +} + +/* --- Sidebar --- */ +.sidebar { + width: 230px; + background: var(--bg-card); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 120; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.sidebar-header { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 15px; + flex-shrink: 0; +} + +.logo-text { + font-weight: 700; + font-size: 17px; + color: var(--accent); + letter-spacing: -0.3px; +} + +.sidebar-nav { + flex: 1; + padding: 4px 0; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} +.sidebar-nav::-webkit-scrollbar { + display: none; +} + +.nav-group { + padding: 10px 10px 2px; +} + +.nav-group-label { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.7px; + padding: 0 8px 6px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: none; + background: none; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + text-align: left; +} + +.nav-item svg { flex-shrink: 0; opacity: 0.7; } +.nav-item span { flex: 1; } +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item:hover svg { opacity: 1; } +.nav-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 600; +} +.nav-item.active svg { opacity: 1; color: var(--accent); } + +.nav-badge { + font-size: 10px; + font-weight: 700; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9px; + padding: 0 5px; + background: var(--danger); + color: white; + line-height: 1; +} + +.sidebar-footer { + margin-top: auto; + padding: 10px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: var(--radius-sm); +} + +.sidebar-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} + +.sidebar-user-info { flex: 1; min-width: 0; } +.sidebar-user-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-user-role { + font-size: 11px; + color: var(--text-muted); + text-transform: capitalize; +} + +.sidebar-logout { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.sidebar-logout:hover { color: var(--danger); background: rgba(192,57,43,0.08); } + +.sidebar-footer .theme-toggle { + width: 100%; + justify-content: center; + border-radius: var(--radius-sm); + height: 32px; +} + +/* --- Main Wrapper --- */ +.main-wrapper { + flex: 1; + margin-left: 230px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 28px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + min-height: 56px; +} + +.topbar-title { + font-size: 18px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.topbar-actions { display: flex; gap: 8px; align-items: center; } + +.topbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-card); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} +.topbar-btn:hover { border-color: var(--accent); color: var(--accent); } +.topbar-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.topbar-btn.primary:hover { background: var(--accent-hover); } + +main { max-width: 960px; margin: 0 auto; padding: 24px 28px; width: 100%; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +/* Generator Grid */ +.generator-grid { display: grid; gap: 20px; align-items: start; } +@media (min-width: 600px) { .generator-grid { grid-template-columns: 1fr 240px; } } + +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-card); +} +.dropzone:hover, .dropzone.dragover { + border-color: var(--accent); + background: var(--accent-light); +} +.dropzone p { color: var(--text-muted); font-size: 14px; line-height: 1.6; } + +.photo-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 8px; margin-top: 12px; } +.photo-preview .thumb { position: relative; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden; box-shadow: var(--shadow-sm); } +.photo-preview .thumb img { width: 100%; height: 100%; object-fit: cover; } +.photo-preview .thumb .remove-photo { + position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; + border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; opacity: 0; transition: opacity 0.15s; +} +.photo-preview .thumb .edit-photo { + position: absolute; top: 4px; left: 4px; width: 24px; height: 24px; + border-radius: 50%; background: var(--accent); color: white; border: 2px solid white; + cursor: pointer; font-size: 12px; display: flex; align-items: center; + justify-content: center; transition: transform 0.15s, background 0.15s; + z-index: 2; box-shadow: 0 1px 4px rgba(0,0,0,0.3); +} +.photo-preview .thumb .edit-photo:hover { background: var(--accent-hover); transform: scale(1.15); } +.photo-preview .thumb:hover .remove-photo { opacity: 1; } + +.image-tools { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + +.config-section { display: flex; flex-direction: column; gap: 14px; } +.config-group label { + display: block; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); margin-bottom: 6px; +} +.button-group { display: flex; gap: 5px; flex-wrap: wrap; } + +.toggle-btn { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-secondary); padding: 6px 12px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; +} +.toggle-btn:hover { border-color: var(--accent); color: var(--accent); } +.toggle-btn.active { background: var(--accent); border-color: var(--accent); color: white; } + +.model-select, select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 7px 10px; border-radius: var(--radius-sm); + font-size: 13px; cursor: pointer; +} +.model-select:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.primary-btn { + background: var(--accent); color: white; border: none; + padding: 10px 20px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; + box-shadow: var(--shadow-sm); +} +.primary-btn:hover { background: var(--accent-hover); } +.primary-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.secondary-btn { + background: var(--bg-card); color: var(--text); border: 1.5px solid var(--border); + padding: 8px 16px; border-radius: var(--radius-sm); font-size: 13px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.secondary-btn:hover { background: var(--bg-hover); border-color: var(--text-muted); } + +.small-btn { + background: var(--bg-card); color: var(--text-secondary); border: 1.5px solid var(--border); + padding: 5px 10px; border-radius: var(--radius-sm); font-size: 12px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.small-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* Meta Section */ +.meta-section { + margin-top: 20px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.meta-section h2 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; } +.meta-grid-edit { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } +.meta-edit-item { display: flex; flex-direction: column; gap: 3px; } +.meta-item { display: flex; flex-direction: column; gap: 2px; } +.meta-label { font-size: 10px; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.6px; font-weight: 600; } +.meta-value { font-size: 14px; font-weight: 500; } +.meta-input { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 8px; border-radius: var(--radius-sm); font-size: 13px; width: 100%; +} +.meta-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.fee-info { + margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); + font-size: 13px; color: var(--text-muted); +} +.fee-info .fee-net { font-size: 16px; font-weight: 600; color: var(--success); } + +/* Variant tabs */ +.variant-tabs { display: flex; gap: 6px; margin: 16px 0 0; } +.variant-tab { + background: var(--bg-card); border: 1.5px solid var(--border); + color: var(--text-muted); padding: 6px 14px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; +} +.variant-tab.active { background: var(--orange); border-color: var(--orange); color: white; } + +/* Results */ +.result-section { + margin-top: 16px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); +} +.result-section h2 { font-size: 15px; margin-bottom: 14px; } +.result-field { margin-bottom: 14px; } +.field-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; } +.field-header label { font-size: 12px; font-weight: 600; color: var(--text-muted); } + +.copy-btn { + background: none; border: 1.5px solid var(--border); color: var(--text-muted); + padding: 3px 8px; border-radius: 6px; font-size: 11px; + cursor: pointer; transition: all 0.15s; font-weight: 500; +} +.copy-btn:hover { border-color: var(--orange); color: var(--orange); } + +.result-input, .result-textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.result-input:focus, .result-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.result-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* Loading */ +.loading { text-align: center; padding: 48px; } +.spinner { + width: 36px; height: 36px; border: 3px solid var(--border); + border-top-color: var(--orange); border-radius: 50%; + margin: 0 auto 16px; animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.loading p { color: var(--text-muted); font-size: 14px; } + +/* History */ +.history-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.history-toolbar input, .history-toolbar select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.history-toolbar input { flex: 1; min-width: 150px; } +.history-toolbar input:focus, .history-toolbar select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +/* History batch bar */ +.history-batch-bar { + display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; + padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); +} +.history-select-all-wrap { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; font-weight: 500; } +.history-select-all-wrap input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.history-batch-bar span { font-size: 13px; color: var(--text-muted); } +.danger-btn { color: var(--danger) !important; border-color: var(--danger) !important; } +.danger-btn:hover { background: rgba(192,57,43,0.08) !important; } +.danger-btn:disabled { opacity: 0.4; } +.history-toolbar input[type="date"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12px; +} + +.history-list { display: flex; flex-direction: column; gap: 6px; } +.history-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; cursor: pointer; transition: all 0.15s; + box-shadow: var(--shadow-sm); +} +.history-item:hover { border-color: var(--accent); box-shadow: var(--shadow); } +.history-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.history-item .item-info { flex: 1; min-width: 0; } +.history-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.history-item .item-actions { display: flex; gap: 4px; align-items: center; } + +.status-badge { + font-size: 10px; padding: 3px 8px; border-radius: 4px; text-transform: uppercase; font-weight: 600; +} +.status-active { background: rgba(44,110,73,0.1); color: var(--success); } +.status-sold { background: rgba(192,57,43,0.1); color: var(--danger); } +.status-reserved { background: var(--orange-light); color: var(--warning); } + +.status-select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 3px 6px; border-radius: 4px; font-size: 11px; cursor: pointer; +} + +.history-item .delete-btn { + background: none; border: 1px solid transparent; color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.history-item .delete-btn:hover { color: var(--danger); border-color: var(--danger); } + +.history-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } + +.history-detail { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); } +.history-detail h3 { margin: 14px 0 6px; font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; } +.history-detail p, .history-detail .detail-value { font-size: 14px; line-height: 1.6; white-space: pre-wrap; } +.history-detail .detail-photos { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.history-detail .detail-photos img { width: 100px; height: 100px; object-fit: cover; border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); } + +/* Detail edit fields */ +.detail-edit-group { margin-bottom: 14px; } +.detail-edit-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 4px; } +.detail-edit-group input, .detail-edit-group textarea, .detail-edit-group select { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.detail-edit-group input:focus, .detail-edit-group textarea:focus, .detail-edit-group select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); +} +.detail-edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 480px) { .detail-edit-row { grid-template-columns: 1fr; } } +.detail-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Templates */ +.templates-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.templates-header h2 { font-size: 16px; } +.template-list { display: flex; flex-direction: column; gap: 6px; } +.template-item { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 16px; + box-shadow: var(--shadow-sm); +} +.template-item .tpl-info { flex: 1; } +.template-item .tpl-name { font-size: 14px; font-weight: 500; } +.template-item .tpl-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.template-item .tpl-actions { display: flex; gap: 6px; } +.template-form { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-top: 16px; box-shadow: var(--shadow-sm); } +.template-form h3 { font-size: 15px; margin-bottom: 16px; } + +/* Fee calculator */ +.fee-results { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; } +.fee-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 14px 18px; + display: flex; justify-content: space-between; align-items: center; + box-shadow: var(--shadow-sm); +} +.fee-card .fee-platform { font-weight: 600; font-size: 14px; } +.fee-card .fee-breakdown { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.fee-card .fee-amounts { text-align: right; } +.fee-card .fee-net-amount { font-size: 18px; font-weight: 700; color: var(--success); } +.fee-card .fee-deducted { font-size: 12px; color: var(--text-muted); } + +/* Inventory */ +.inventory-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.inventory-header h2 { margin: 0; } +.inventory-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; } +.inv-stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; text-align: center; } +.inv-stat-value { font-size: 24px; font-weight: 700; color: var(--accent); } +.inv-stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.inventory-locations { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } +.inv-location-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } +.inv-location-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.06); } +.inv-loc-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.inv-loc-icon { color: var(--accent); flex-shrink: 0; } +.inv-loc-name { font-weight: 600; font-size: 15px; } +.inv-loc-stats { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text-muted); } +.inv-loc-count { font-weight: 500; color: var(--text); } +.inv-loc-sold { color: var(--orange); } +.inv-loc-reserved { color: var(--accent); } +.inv-loc-value { margin-top: 8px; font-size: 16px; font-weight: 600; color: var(--accent); } +.inv-unassigned { border-style: dashed; opacity: 0.8; } +.inv-unassigned .inv-loc-icon { color: var(--text-muted); } +.inventory-detail-actions { display: flex; gap: 8px; margin: 12px 0; } +.inv-item-check { margin-right: 10px; } + +/* Settings */ +.settings-form { max-width: 500px; } +.setting-group { margin-bottom: 18px; } +.setting-group > label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; } +.setting-group small { display: block; margin-top: 3px; color: var(--text-muted); font-size: 12px; } +.setting-group input, .setting-group select, .setting-group textarea { + width: 100%; background: var(--bg-input); border: 1.5px solid var(--border); + color: var(--text); padding: 9px 11px; border-radius: var(--radius-sm); + font-size: 14px; font-family: inherit; resize: vertical; +} +.setting-group input:focus, .setting-group select:focus, .setting-group textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.key-input-wrap { display: flex; gap: 6px; } +.key-input-wrap input { flex: 1; } +.toggle-visibility { + background: var(--bg-card); border: 1.5px solid var(--border); color: var(--text-muted); + padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; white-space: nowrap; + font-weight: 500; +} +.toggle-visibility:hover { border-color: var(--accent); color: var(--accent); } + +/* Toast */ +.toast { + position: fixed; bottom: 24px; right: 24px; + background: var(--bg-card); border: 1.5px solid var(--success); + color: var(--text); padding: 10px 18px; border-radius: var(--radius-sm); + font-size: 14px; font-weight: 500; transform: translateY(100px); opacity: 0; + transition: all 0.3s; z-index: 1000; box-shadow: var(--shadow); +} +.toast.show { transform: translateY(0); opacity: 1; } + +/* Publish tab */ +.publish-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.publish-header h2 { font-size: 16px; } +.publish-actions-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.publish-actions-bar span { font-size: 13px; color: var(--text-muted); font-weight: 500; } +.publish-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.publish-filters input, .publish-filters select { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; +} +.publish-filters input { flex: 1; min-width: 150px; } +.publish-filters input:focus, .publish-filters select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } + +.publish-list { display: flex; flex-direction: column; gap: 6px; } +.publish-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); +} +.publish-item:hover { border-color: var(--accent); } +.publish-item input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); flex-shrink: 0; } +.publish-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); } +.publish-item .item-info { flex: 1; min-width: 0; } +.publish-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.publish-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.publish-item .item-publish-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.publish-badges { display: flex; gap: 4px; margin-right: 8px; } +.publish-badge { + font-size: 9px; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; font-weight: 600; +} +.publish-badge.copied { background: var(--orange-light); color: var(--orange); } +.publish-badge.published { background: rgba(44,110,73,0.1); color: var(--success); } + +/* Modal */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 500; + display: flex; align-items: center; justify-content: center; padding: 24px; +} +.modal { + background: var(--bg-card); border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.15); + max-width: 560px; width: 100%; max-height: 85vh; overflow-y: auto; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); + position: sticky; top: 0; background: var(--bg-card); z-index: 1; +} +.modal-header h3 { font-size: 15px; } +.modal-close { + background: none; border: none; font-size: 22px; color: var(--text-muted); + cursor: pointer; padding: 0 4px; line-height: 1; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 20px; } + +.smart-copy-field { margin-bottom: 14px; } +.smart-copy-field .field-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } +.smart-copy-field label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); } +.smart-copy-field input, .smart-copy-field textarea { + width: 100%; background: var(--bg-page); border: 1.5px solid var(--border); + color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); + font-size: 13px; font-family: inherit; resize: vertical; +} +.smart-copy-field textarea { min-height: 80px; } + +.smart-copy-steps { + background: var(--accent-light); border-radius: var(--radius-sm); + padding: 14px 16px; margin-bottom: 16px; +} +.smart-copy-steps h4 { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--accent); } +.smart-copy-steps ol { padding-left: 18px; font-size: 13px; line-height: 1.8; color: var(--text-secondary); } + +.smart-copy-photos { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.smart-copy-photos img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; box-shadow: var(--shadow-sm); } + +.smart-copy-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* Etsy connect */ +.etsy-connect-wrap { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.etsy-status { font-size: 12px; font-weight: 600; } +.etsy-status.connected { color: var(--success); } +.etsy-status.disconnected { color: var(--text-muted); } + +/* HTML Preview */ +.html-preview { + background: #fff; border: 1.5px solid var(--border); border-radius: var(--radius-sm); + padding: 20px; min-height: 80px; line-height: 1.6; font-size: 14px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.04); +} +.html-source { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; line-height: 1.5; white-space: pre-wrap; tab-size: 2; + background: #2c2c2a; color: #e8e8e0; border-radius: var(--radius-sm); + padding: 14px; +} + +/* Quill editor overrides */ +.ql-toolbar.ql-snow { + border: 1.5px solid var(--border) !important; + border-radius: var(--radius-sm) var(--radius-sm) 0 0 !important; + background: var(--bg-page) !important; + font-family: inherit !important; +} +.ql-container.ql-snow { + border: 1.5px solid var(--border) !important; + border-top: none !important; + border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important; + font-family: inherit !important; + font-size: 14px !important; + min-height: 120px; + background: var(--bg-input); +} +.ql-editor { min-height: 100px; line-height: 1.7; color: var(--text); } +.ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; } +.ql-snow .ql-stroke { stroke: var(--text-secondary) !important; } +.ql-snow .ql-fill { fill: var(--text-secondary) !important; } +.ql-snow .ql-picker-label { color: var(--text-secondary) !important; } +.ql-snow .ql-active .ql-stroke { stroke: var(--accent) !important; } +.ql-snow .ql-active .ql-fill { fill: var(--accent) !important; } +.ql-snow .ql-active { color: var(--accent) !important; } +.ql-snow .ql-picker-options { background: var(--bg-card) !important; border-color: var(--border) !important; border-radius: var(--radius-sm) !important; } +.ql-editor:focus { box-shadow: inset 0 0 0 1px var(--accent); } + +.hidden { display: none !important; } +.empty-state { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; } + +/* Category Picker */ +.cat-picker-wrap { position: relative; } +.cat-picker-wrap .cat-search { width: 100%; } +.cat-dropdown { + display: none; position: absolute; top: 100%; left: 0; right: 0; + background: var(--bg-card); border: 1px solid var(--border); + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + max-height: 260px; overflow-y: auto; z-index: 20; + scrollbar-width: thin; +} +.cat-empty { padding: 14px; text-align: center; color: var(--text-muted); font-size: 13px; } +.cat-result-item { + padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--bg-hover); +} +.cat-result-item:hover { background: var(--accent-light); } +.cat-result-name { font-weight: 600; font-size: 13px; color: var(--text); } +.cat-result-path { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.cat-group-hd { + padding: 7px 10px; font-size: 13px; font-weight: 700; color: var(--accent); + cursor: pointer; background: var(--bg-hover); border-bottom: 1px solid var(--border); +} +.cat-group-hd:hover { background: var(--bg-page); } +.cat-group-hd::before { content: '▸ '; font-size: 10px; color: var(--text-muted); } +.cat-group-hd.expanded::before { content: '▾ '; } +.cat-sub-hd { + padding: 5px 10px 5px 18px; font-size: 12px; font-weight: 600; + color: var(--text-secondary); background: var(--bg); +} +.cat-leaf { + padding: 5px 10px 5px 28px; font-size: 12px; cursor: pointer; color: var(--text); + border-bottom: 1px solid rgba(0,0,0,0.03); +} +.cat-leaf:hover { background: var(--accent-light); color: var(--accent); } +.cat-selected-display { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; background: var(--accent-light); + border: 1px solid var(--accent); font-size: 13px; + color: var(--accent); font-weight: 500; +} +.cat-sel-clear { + margin-left: auto; cursor: pointer; font-size: 16px; line-height: 1; + color: var(--text-muted); background: none; border: none; padding: 0 2px; +} +.cat-sel-clear:hover { color: var(--danger); } + +/* SKU Badge */ +.sku-badge { + display: inline-block; font-size: 10px; font-weight: 700; + background: var(--accent-light); color: var(--accent); + padding: 1px 5px; margin-right: 6px; letter-spacing: 0.3px; +} + +/* Publish badges in history */ +.item-pub-badges { display: flex; gap: 4px; margin-top: 3px; flex-wrap: wrap; } +.publish-badge { + font-size: 9px; font-weight: 600; padding: 1px 6px; + text-transform: uppercase; letter-spacing: 0.3px; +} +.pub-published { background: #e8f5e9; color: var(--accent); } +.pub-copied { background: var(--orange-light); color: var(--orange); } +.pub-pending { background: #f0f0f0; color: #999; } +.pub-failed { background: #fce4e4; color: #c62828; } + +/* Detail header with QR */ +.detail-header-row { display: flex; gap: 16px; align-items: flex-start; } +.detail-header-row .detail-photos { flex: 1; } +.detail-qr-wrap { + flex-shrink: 0; display: flex; flex-direction: column; + align-items: center; gap: 4px; padding: 8px; + background: var(--bg); border: 1px solid var(--border); +} +.detail-sku-big { font-size: 14px; font-weight: 800; color: var(--accent); letter-spacing: 0.5px; } + +/* Dark Mode */ +[data-theme="dark"] { + --bg: #1a1a1e; + --bg-card: #242428; + --bg-input: #2c2c30; + --bg-hover: #32323a; + --bg-page: #18181c; + --border: #3a3a42; + --border-focus: #3d9e6a; + --text: #e4e4e0; + --text-muted: #8a8a90; + --text-secondary: #b0b0b4; + --accent: #3d9e6a; + --accent-hover: #4db87a; + --accent-light: rgba(61,158,106,0.12); + --orange: #e09040; + --orange-hover: #d08030; + --orange-light: rgba(224,144,64,0.12); + --success: #3d9e6a; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.2); + --shadow: 0 2px 8px rgba(0,0,0,0.3); +} +[data-theme="dark"] .sidebar { background: #1e1e22; border-color: var(--border); } +[data-theme="dark"] .html-preview { background: var(--bg-input); } +[data-theme="dark"] .ql-editor { color: var(--text); } +[data-theme="dark"] .ql-container.ql-snow { background: var(--bg-input) !important; } +[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--bg-hover) !important; } + +.theme-toggle { + background: none; border: 1px solid var(--border); color: var(--text-muted); + height: 32px; border-radius: var(--radius-sm); cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 14px; transition: all 0.15s; flex-shrink: 0; + padding: 0 8px; gap: 6px; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } + +/* Dashboard */ +.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.stat-card .stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); font-weight: 600; } +.stat-card .stat-value { font-size: 28px; font-weight: 800; color: var(--text); margin-top: 4px; } +.stat-card .stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.orange .stat-value { color: var(--orange); } + +.chart-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-sm); + margin-bottom: 16px; +} +.chart-card h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); } +.chart-bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.chart-bar-label { font-size: 12px; min-width: 100px; color: var(--text-secondary); } +.chart-bar-track { flex: 1; height: 22px; background: var(--bg-hover); border-radius: 4px; overflow: hidden; } +.chart-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; display: flex; align-items: center; padding-left: 8px; font-size: 11px; font-weight: 600; color: #fff; } +.chart-bar-fill.green { background: var(--accent); } +.chart-bar-fill.orange { background: var(--orange); } +.chart-bar-fill.blue { background: #4a90d9; } + +/* Advanced Image Editor */ +.photo-editor-overlay { + position: fixed; inset: 0; z-index: 600; display: flex; align-items: center; justify-content: center; + padding: 18px; background: rgba(8, 10, 12, .78); backdrop-filter: blur(8px); +} +.photo-editor { + width: min(1420px, 98vw); max-height: 94vh; overflow: hidden; display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--vd-surface, var(--bg-card)); + border: 1px solid var(--vd-line, var(--border)); border-radius: 16px; box-shadow: 0 28px 90px rgba(0,0,0,.42); +} +.photo-editor-header { + display: flex; justify-content: space-between; align-items: center; gap: 18px; + padding: 15px 18px; border-bottom: 1px solid var(--vd-line, var(--border)); +} +.photo-editor-header > div { min-width: 0; } +.photo-editor-header h3 { margin: 2px 0 0; color: var(--vd-ink, var(--text)); font-size: 18px; } +.photo-editor-header small { display: block; margin-top: 4px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 11px; } +.photo-editor-kicker { color: var(--vd-brand, var(--accent)); font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; } +.photo-editor-toolbar { + display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding: 9px 18px; + border-bottom: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface-muted, var(--bg-page)); +} +.photo-editor-toolbar .small-btn.active, .pe-button-grid .small-btn.active { background: var(--vd-brand, var(--accent)); color: #fff; border-color: transparent; } +.pe-history-status { margin-left: auto; color: var(--vd-ink-muted, var(--text-muted)); font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.photo-editor-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 360px; overflow: hidden; } +.photo-editor-stage { + min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: stretch; + gap: 10px; padding: 14px; overflow: auto; background: + linear-gradient(45deg, rgba(120,120,120,.06) 25%, transparent 25%), + linear-gradient(-45deg, rgba(120,120,120,.06) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(120,120,120,.06) 75%), + linear-gradient(-45deg, transparent 75%, rgba(120,120,120,.06) 75%); + background-size: 24px 24px; background-position: 0 0, 0 12px, 12px -12px, -12px 0; +} +.photo-editor-stage:has(.pe-before-pane:not(.hidden)) { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.pe-preview-pane { min-width: 0; min-height: 420px; position: relative; display: flex; align-items: center; justify-content: center; padding: 28px 12px 12px; border: 1px solid var(--vd-line, var(--border)); border-radius: 12px; background: rgba(15,18,20,.78); } +.pe-preview-pane > span { position: absolute; top: 8px; left: 10px; z-index: 2; padding: 4px 7px; border-radius: 999px; color: #fff; background: rgba(0,0,0,.58); font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .08em; } +.pe-preview-pane canvas { display: block; max-width: 100%; max-height: calc(94vh - 250px); object-fit: contain; border-radius: 5px; box-shadow: 0 12px 36px rgba(0,0,0,.34); } +.photo-editor-canvas-wrap { min-width: 0; min-height: 0; width: 100%; height: 100%; position: relative; display: flex; align-items: center; justify-content: center; } +.photo-editor-canvas-wrap .crop-overlay { position: absolute; z-index: 4; cursor: crosshair; touch-action: none; border-radius: 5px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); } +.pe-stage-footer { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--vd-ink-muted, var(--text-muted)); font-size: 10px; } +.photo-editor-sidebar { min-height: 0; overflow-y: auto; padding: 12px; border-left: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface-muted, var(--bg-page)); } +.pe-control-group { margin-bottom: 9px; border: 1px solid var(--vd-line, var(--border)); border-radius: 10px; background: var(--vd-surface, var(--bg-card)); overflow: hidden; } +.pe-control-group > summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; padding: 10px 11px; color: var(--vd-ink, var(--text)); font-size: 11px; font-weight: 800; } +.pe-control-group > summary::-webkit-details-marker { display: none; } +.pe-control-group > summary::after { content: '⌄'; color: var(--vd-ink-muted, var(--text-muted)); } +.pe-control-group:not([open]) > summary::after { content: '›'; } +.pe-control-body { display: grid; gap: 9px; padding: 10px 11px 12px; border-top: 1px solid var(--vd-line, var(--border)); } +.pe-control-body .editor-row { display: grid; grid-template-columns: 88px minmax(0,1fr) 44px; gap: 8px; align-items: center; } +.pe-control-body .editor-row label, .pe-export-grid > label, .pe-crop-controls > label { color: var(--vd-ink-muted, var(--text-muted)); font-size: 10px; font-weight: 700; } +.pe-control-body input[type="range"] { width: 100%; accent-color: var(--vd-brand, var(--accent)); } +.pe-control-body .range-val { min-width: 40px; color: var(--vd-ink-secondary, var(--text-secondary)); font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; } +.pe-button-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; } +.pe-button-grid.three { grid-template-columns: repeat(3, minmax(0,1fr)); } +.pe-button-grid .small-btn { justify-content: center; } +.pe-crop-controls { display: grid; grid-template-columns: 88px minmax(0,1fr); gap: 8px; align-items: center; } +.pe-crop-controls .pe-button-grid { grid-column: 1 / -1; } +.pe-control-body select, .pe-control-body input[type="number"] { width: 100%; min-height: 34px; padding: 7px 9px; border: 1px solid var(--vd-line, var(--border)); border-radius: 7px; color: var(--vd-ink, var(--text)); background: var(--vd-surface, var(--bg-input)); } +.pe-export-grid { grid-template-columns: 88px minmax(0,1fr); align-items: center; } +.pe-inline-control { display: grid; grid-template-columns: minmax(0,1fr) 40px; gap: 8px; align-items: center; } +.pe-custom-size { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto 1fr; gap: 7px; align-items: center; } +.pe-version-list { display: grid; gap: 6px; max-height: 210px; overflow-y: auto; } +.pe-version-item { width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr); gap: 8px; align-items: center; padding: 8px; border: 1px solid var(--vd-line, var(--border)); border-radius: 8px; color: var(--vd-ink, var(--text)); background: var(--vd-surface-muted, var(--bg-page)); text-align: left; cursor: pointer; } +.pe-version-item:hover, .pe-version-item.active { border-color: var(--vd-brand, var(--accent)); background: color-mix(in srgb, var(--vd-brand, var(--accent)) 9%, var(--vd-surface, var(--bg-card))); } +.pe-version-item > span { display: grid; place-items: center; min-height: 30px; border-radius: 6px; color: var(--vd-brand, var(--accent)); background: color-mix(in srgb, var(--vd-brand, var(--accent)) 12%, transparent); font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.pe-version-item strong { display: block; font-size: 10px; } +.pe-version-item small { display: block; margin-top: 3px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 8px; line-height: 1.35; } +.photo-editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--vd-line, var(--border)); background: var(--vd-surface, var(--bg-card)); } +.photo-editor-actions > div { margin-right: auto; } +.photo-editor-actions strong { display: block; color: var(--vd-ink, var(--text)); font-size: 10px; } +.photo-editor-actions small { display: block; margin-top: 2px; color: var(--vd-ink-muted, var(--text-muted)); font-size: 9px; } +@media (max-width: 980px) { + .photo-editor { max-height: 96vh; } + .photo-editor-workspace { grid-template-columns: 1fr; overflow-y: auto; } + .photo-editor-sidebar { overflow: visible; border-left: 0; border-top: 1px solid var(--vd-line, var(--border)); } + .pe-preview-pane { min-height: 320px; } + .pe-preview-pane canvas { max-height: 52vh; } +} +@media (max-width: 680px) { + .photo-editor-overlay { padding: 0; } + .photo-editor { width: 100vw; max-height: 100vh; min-height: 100vh; border-radius: 0; } + .photo-editor-toolbar { padding-inline: 10px; } + .photo-editor-stage:has(.pe-before-pane:not(.hidden)) { grid-template-columns: 1fr; } + .photo-editor-stage { padding: 9px; } + .pe-preview-pane { min-height: 280px; } + .pe-history-status { width: 100%; margin-left: 0; } + .photo-editor-actions { flex-wrap: wrap; } + .photo-editor-actions > div { width: 100%; margin-right: 0; } + .photo-editor-actions button { flex: 1; } +} + + +/* Drag handle for sortable items */ +.drag-handle { + cursor: grab; color: var(--text-muted); font-size: 16px; padding: 0 4px; + user-select: none; touch-action: none; +} +.drag-handle:active { cursor: grabbing; } +.photo-preview .thumb.dragging { opacity: 0.4; } +.photo-preview .thumb.drag-over { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Scheduling */ +.schedule-wrap { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.schedule-wrap input[type="datetime-local"] { + background: var(--bg-input); border: 1.5px solid var(--border); color: var(--text); + padding: 6px 10px; border-radius: var(--radius-sm); font-size: 13px; +} + +/* Backup section */ +.backup-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); } +.backup-section h3 { font-size: 14px; margin-bottom: 12px; } +.backup-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* --- Role Badge (used in admin) --- */ +.role-badge { + display: inline-block; padding: 2px 10px; border-radius: 12px; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 6px; +} +.role-badge.admin { background: #ffeaa7; color: #856404; } +.role-badge.editor { background: #dfe6e9; color: #636e72; } + +/* --- Admin Panel --- */ +.admin-wrap { max-width: 900px; } +.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } +.admin-header h2 { font-size: 18px; } +.admin-tabs { display: flex; gap: 2px; margin-bottom: 16px; } +.admin-tab { + padding: 8px 16px; background: var(--bg); border: 1px solid var(--border); + border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; font-weight: 500; + color: var(--text-muted); transition: all 0.15s; border-bottom: none; +} +.admin-tab.active { background: var(--bg-card); color: var(--text); font-weight: 600; border-bottom: 2px solid var(--accent); } +.admin-panel { display: none; } +.admin-panel.active { display: block; } + +/* User list */ +.users-list { display: flex; flex-direction: column; gap: 8px; } +.user-card { + display: flex; align-items: center; gap: 14px; padding: 14px 18px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; + transition: box-shadow 0.15s; +} +.user-card:hover { box-shadow: var(--shadow); } +.user-card-avatar { + width: 40px; height: 40px; border-radius: 50%; + font-size: 14px; font-weight: 700; color: white; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.user-card-info { flex: 1; min-width: 0; } +.user-card-info .name { font-weight: 600; font-size: 14px; color: var(--text); } +.user-card-info .email { font-size: 12px; color: var(--text-muted); } +.user-card-info .meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; } +.user-card-actions { display: flex; gap: 6px; align-items: center; } +.user-card .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.user-card .status-dot.online { background: #27ae60; } +.user-card .status-dot.offline { background: #bbb; } +.user-card .status-dot.inactive { background: var(--danger); } + +/* Sessions list */ +.sessions-list .session-row { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; + margin-bottom: 6px; font-size: 13px; +} +.session-row .session-info { flex: 1; } +.session-row .session-info .session-ua { font-size: 11px; color: var(--text-muted); } +.session-row .session-info .session-ip { font-size: 11px; color: var(--text-muted); } +.session-row .session-meta { font-size: 11px; color: var(--text-muted); text-align: right; min-width: 100px; } + +/* Audit log */ +.audit-list { max-height: 600px; overflow-y: auto; } +.audit-row { + display: flex; gap: 10px; padding: 8px 12px; font-size: 12px; + border-bottom: 1px solid var(--border); align-items: center; +} +.audit-row:hover { background: var(--bg-hover); } +.audit-row .audit-time { min-width: 130px; color: var(--text-muted); font-size: 11px; } +.audit-row .audit-user { min-width: 120px; font-weight: 500; } +.audit-row .audit-action { flex: 1; } +.audit-action-badge { + display: inline-block; padding: 1px 8px; border-radius: 4px; + font-size: 10px; font-weight: 600; text-transform: uppercase; +} +.audit-action-badge.login { background: #e3f2fd; color: #1565c0; } +.audit-action-badge.user { background: #fce4ec; color: #c62828; } +.audit-action-badge.listing { background: #e8f5e9; color: #2e7d32; } +.audit-action-badge.session { background: #fff3e0; color: #e65100; } + +/* Modals (reusable) */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 300; + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.modal-overlay.hidden { display: none; } +.modal { + background: var(--bg-card); border-radius: 12px; width: 100%; + box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); +} +.modal-header h3 { font-size: 15px; } +.modal-body { padding: 20px; } +.modal-footer { + padding: 14px 20px; border-top: 1px solid var(--border); + display: flex; gap: 8px; justify-content: flex-end; +} +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 5px; } +.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; background: var(--bg-input); } +.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(44,110,73,0.1); } + +/* SMTP Config */ +.smtp-config-form { max-width: 500px; } +.smtp-status-bar { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; margin-bottom: 18px; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; +} +.smtp-status-dot { + width: 10px; height: 10px; border-radius: 50%; + flex-shrink: 0; display: inline-block; +} +.smtp-status-dot.configured { background: var(--success); } +.smtp-status-dot.not-configured { background: var(--text-muted); } +.smtp-actions { display: flex; gap: 8px; margin-top: 16px; } + +/* System Info Grid */ +.system-info-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.system-info-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow-sm); +} +.system-info-card .sys-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; + color: var(--text-muted); font-weight: 600; +} +.system-info-card .sys-value { + font-size: 22px; font-weight: 700; color: var(--text); margin-top: 4px; +} +.system-info-card .sys-value.accent { color: var(--accent); } + +/* Password Status Badge */ +.pw-status-badge { + display: inline-block; padding: 3px 10px; border-radius: 4px; + font-size: 11px; font-weight: 600; +} +.pw-status-badge.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-status-badge.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Login History in Edit Modal */ +.login-history-list { + max-height: 200px; overflow-y: auto; margin-top: 8px; + border: 1px solid var(--border); border-radius: var(--radius-sm); +} +.login-history-item { + display: flex; gap: 8px; align-items: center; + padding: 6px 10px; font-size: 11px; + border-bottom: 1px solid var(--border); +} +.login-history-item:last-child { border-bottom: none; } +.login-history-item .lh-time { min-width: 110px; color: var(--text-muted); } +.login-history-item .lh-ip { color: var(--text-secondary); min-width: 90px; } +.login-history-item .lh-status { font-weight: 600; } +.login-history-item .lh-status.success { color: var(--success); } +.login-history-item .lh-status.failed { color: var(--danger); } + +/* User card has_password indicator */ +.user-card .pw-indicator { + font-size: 10px; padding: 1px 6px; border-radius: 3px; + font-weight: 600; display: inline-block; margin-left: 6px; +} +.pw-indicator.has-pw { background: rgba(44,110,73,0.1); color: var(--success); } +.pw-indicator.no-pw { background: rgba(199,123,42,0.1); color: var(--warning); } + +/* Pagination */ +.pagination-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 14px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); margin-bottom: 8px; box-shadow: var(--shadow-sm); + flex-wrap: wrap; gap: 8px; +} +#history-pagination-bottom, #publish-pagination-bottom { + margin-top: 8px; margin-bottom: 0; +} +.pagination-sizes { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); } +.page-size-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-size-btn:hover { border-color: var(--accent); color: var(--accent); } +.page-size-btn.active { background: var(--accent); border-color: var(--accent); color: white; } +.pagination-nav { display: flex; align-items: center; gap: 8px; } +.page-nav-btn { + background: var(--bg-card); border: 1px solid var(--border); color: var(--text); + padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; + transition: all 0.15s; +} +.page-nav-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.page-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.page-info { font-size: 12px; color: var(--text-muted); font-weight: 500; min-width: 100px; text-align: center; } + +/* Trash / Papierkorb */ +.trash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; } +.trash-header h2 { font-size: 16px; } +.trash-actions-bar { display: flex; align-items: center; gap: 12px; } +.trash-info-text { font-size: 12px; color: var(--text-muted); } +.trash-list { display: flex; flex-direction: column; gap: 6px; } +.trash-item { + display: flex; align-items: center; gap: 12px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 10px 14px; transition: all 0.15s; box-shadow: var(--shadow-sm); + opacity: 0.7; +} +.trash-item:hover { opacity: 1; border-color: var(--border); } +.trash-item .thumb-small { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--bg-page); filter: grayscale(40%); } +.trash-item .item-info { flex: 1; min-width: 0; } +.trash-item .item-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trash-item .item-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.trash-item .trash-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.trash-item .restore-btn { + background: none; border: 1px solid var(--accent); color: var(--accent); + padding: 3px 10px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .restore-btn:hover { background: var(--accent); color: white; } +.trash-item .perm-delete-btn { + background: none; border: 1px solid var(--danger); color: var(--danger); + padding: 3px 8px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; +} +.trash-item .perm-delete-btn:hover { background: var(--danger); color: white; } + +/* Trash badge on tab */ +.trash-badge { + display: inline-flex; align-items: center; justify-content: center; + background: var(--danger); color: white; + font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; + border-radius: 8px; padding: 0 4px; margin-left: 4px; + line-height: 1; +} + +/* Invite method radios */ +.invite-method-radios { display: flex; flex-direction: column; gap: 8px; } +.radio-label { + display: flex; align-items: center; gap: 8px; cursor: pointer; + font-size: 13px; color: var(--text); +} +.radio-label input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; } +.radio-label small { color: var(--text-muted); } + +/* Duplicate & bulk status button */ +.duplicate-btn { + background: none; border: 1px solid var(--border); color: var(--text-muted); + padding: 3px 6px; border-radius: var(--radius-sm); cursor: pointer; + font-size: 11px; transition: all 0.15s; +} +.duplicate-btn:hover { color: var(--accent); border-color: var(--accent); } + +/* Bulk status modal */ +.bulk-status-options { display: flex; gap: 8px; margin-top: 12px; } + +/* Sidebar mobile toggle */ +.sidebar-toggle { + display: none; + background: none; border: none; color: var(--text); + cursor: pointer; padding: 4px; +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform 0.25s ease; + box-shadow: none; + } + .sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0,0,0,0.15); + } + .main-wrapper { margin-left: 0; } + .sidebar-toggle { display: flex; } + .sidebar-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.3); + z-index: 110; display: none; + } + .sidebar-overlay.show { display: block; } +} + +@media (max-width: 480px) { + main { padding: 14px; } + .dashboard-grid { grid-template-columns: 1fr 1fr; } + .admin-header { flex-direction: column; gap: 10px; align-items: flex-start; } + .user-card { flex-direction: column; text-align: center; } + .system-info-grid { grid-template-columns: 1fr 1fr; } + .admin-tabs { flex-wrap: wrap; } + .pagination-bar { flex-direction: column; } + .topbar-actions { gap: 4px; } + .topbar-btn span { display: none; } +} + +/* ========================================================================== + Vendoo Design System Contract 1.0 + Tokenquelle: public/design-system/tokens/source.json + Generiert: public/design-system/tokens.css + ========================================================================== */ + +html { background: var(--vd-canvas); } +body { + background: var(--vd-canvas); + color: var(--vd-ink); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.008em; +} +button, input, select, textarea { font: inherit; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { + outline: 3px solid var(--vd-focus); + outline-offset: 2px; +} + +/* App shell */ +.sidebar { + width: var(--vd-sidebar-width); + background: var(--vd-surface); + border-right: 1px solid var(--vd-line); + padding: 0; + overflow: hidden; +} +.sidebar-header { + height: var(--vd-topbar-height); + padding: 0 22px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--vd-line); +} +.sidebar-logo { gap: 9px; color: var(--vd-brand); } +.logo-mark { flex: 0 0 auto; } +.logo-text { + color: var(--vd-ink); + font-size: 18px; + font-weight: 750; + letter-spacing: -0.045em; +} +.sidebar-nav { padding: 14px 10px 8px; } +.nav-group { padding: 0; } +.nav-group-primary { display: flex; flex-direction: column; gap: 5px; } +.nav-group-tools { margin-top: 14px; display: flex; flex-direction: column; gap: 3px; } +.nav-divider { height: 1px; background: var(--vd-line); margin: 2px 10px 12px; } +.nav-item { + gap: 11px; + min-height: 42px; + padding: 0 12px; + border-radius: var(--vd-radius-sm); + color: var(--vd-ink-secondary); + font-size: 13px; + font-weight: 540; + position: relative; +} +.nav-item::before { + content: ""; + position: absolute; + left: -10px; + width: 2px; + height: 22px; + border-radius: 2px; + background: transparent; +} +.nav-item svg { opacity: .78; } +.nav-item:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.nav-item.active { + color: var(--vd-brand); + background: var(--vd-brand-soft); + font-weight: 650; +} +.nav-item.active::before { background: var(--vd-brand); } +.nav-item.active svg { color: var(--vd-brand); } +.nav-item-quiet { min-height: 36px; font-size: 12px; } +.nav-badge { margin-left: auto; background: var(--vd-danger); } +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface); + gap: 6px; +} +.sidebar-user { padding: 6px; gap: 8px; } +.sidebar-user-avatar, .topbar-avatar { + background: var(--vd-ink); + color: var(--vd-surface-raised); +} +.sidebar-user-info { display: none; } +.sidebar-logout { margin-left: auto; } +.sidebar-footer .theme-toggle { + height: 34px; + justify-content: flex-start; + padding: 0 9px; + gap: 9px; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 12px; +} +.sidebar-footer .theme-toggle:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } + +.main-wrapper { margin-left: var(--vd-sidebar-width); } +.topbar { + min-height: var(--vd-topbar-height); + height: var(--vd-topbar-height); + padding: 0 22px 0 26px; + background: color-mix(in srgb, var(--vd-surface) 94%, transparent); + border-bottom: 1px solid var(--vd-line); + backdrop-filter: blur(14px); + gap: 22px; +} +.topbar-context { display: flex; align-items: center; gap: 10px; min-width: 185px; } +.topbar-context-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + color: var(--vd-ink-secondary); +} +.topbar-heading { display: flex; align-items: baseline; gap: 9px; } +.topbar-title { font-size: 14px; font-weight: 700; letter-spacing: -0.025em; white-space: nowrap; } +.topbar-kicker { font-size: 11px; color: var(--vd-ink-muted); white-space: nowrap; } +.command-search { + width: min(470px, 42vw); + height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px 0 12px; + cursor: pointer; + text-align: left; +} +.command-search:hover { border-color: var(--vd-line-strong); color: var(--vd-ink-secondary); } +.command-search span { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.command-search kbd { + min-width: 34px; + padding: 2px 5px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-xs); + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: center; +} +.topbar-actions { margin-left: auto; gap: 7px; } +.topbar-btn { + height: 36px; + border-radius: var(--vd-radius-sm); + padding: 0 14px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); +} +.topbar-btn.primary { + background: var(--vd-brand); + border-color: var(--vd-brand); + box-shadow: 0 5px 14px var(--vd-brand-soft); +} +.topbar-btn.primary:hover { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); } +.topbar-icon-btn { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 0; + background: transparent; + color: var(--vd-ink-secondary); + border-radius: 50%; + position: relative; + cursor: pointer; +} +.topbar-icon-btn:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.topbar-dot { + position: absolute; + top: 6px; + right: 7px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vd-brand); + box-shadow: 0 0 0 2px var(--vd-surface); +} +.topbar-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 750; +} + +main { + max-width: none; + margin: 0; + padding: 20px 24px 28px; +} +.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; } +.page-heading h2 { font-size: 22px; line-height: 1.15; letter-spacing: -0.045em; } +.page-heading p { margin-top: 5px; color: var(--vd-ink-secondary); font-size: 12px; } +.eyebrow { + margin: 0 0 7px !important; + color: var(--vd-brand) !important; + font-size: 10px !important; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .12em; +} + +/* Studio Flow */ +.studio-flow-page { min-width: 0; } +.studio-summary { display: flex; align-items: center; gap: 14px; color: var(--vd-ink-muted); font-size: 11px; } +.studio-summary strong { color: var(--vd-ink); font-size: 13px; } +.studio-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 14px; + align-items: start; +} +.studio-workspace { min-width: 0; } +.flow-board-wrap { + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: var(--vd-line-strong) transparent; +} +.flow-board { + min-width: 1010px; + display: grid; + grid-template-columns: repeat(6, minmax(150px, 1fr)); + gap: 10px; +} +.flow-column { min-width: 0; } +.flow-column-header { + min-height: 34px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 4px 7px; + border-bottom: 1px solid var(--vd-line); + position: relative; +} +.flow-column:not(:last-child) .flow-column-header::after { + content: "→"; + position: absolute; + right: -10px; + top: 7px; + color: var(--vd-ink-muted); + font-size: 10px; + z-index: 2; +} +.flow-column-title { font-size: 11px; font-weight: 720; color: var(--vd-ink); } +.flow-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 700; +} +.flow-column-list { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; } +.flow-card { + width: 100%; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; + transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; +} +.flow-card:hover { + border-color: var(--vd-line-strong); + transform: translateY(-1px); + box-shadow: var(--vd-shadow-soft); +} +.flow-card.selected { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.flow-card-media { + aspect-ratio: 1.45; + overflow: hidden; + border-radius: 6px; + background: var(--vd-surface-muted); + margin-bottom: 7px; + position: relative; +} +.flow-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; } +.flow-card-placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--vd-ink-muted); } +.flow-card-title { + min-height: 30px; + font-size: 11px; + line-height: 1.32; + font-weight: 680; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.flow-card-sku { margin-top: 4px; color: var(--vd-ink-muted); font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; } +.flow-card-footer { margin-top: 7px; display: flex; align-items: center; gap: 5px; min-height: 20px; } +.flow-platform { font-size: 9px; font-weight: 780; text-transform: uppercase; letter-spacing: .025em; } +.flow-price { margin-left: auto; font-size: 10px; font-weight: 730; white-space: nowrap; } +.flow-state { + margin-left: auto; + min-height: 19px; + padding: 2px 7px; + border-radius: 10px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; + font-weight: 700; + white-space: nowrap; +} +.flow-state.success { background: var(--vd-success-soft); color: var(--vd-success); } +.flow-state.warning { background: var(--vd-warning-soft); color: var(--vd-warning); } +.flow-state.brand { background: var(--vd-brand-soft); color: var(--vd-brand); } +.flow-progress { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-brand) var(--progress), var(--vd-surface-muted) 0); position: relative; } +.flow-progress::after { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--vd-surface-raised); } +.flow-progress span { position: relative; z-index: 1; font-size: 8px; font-weight: 760; } +.flow-empty { + min-height: 116px; + border: 1px dashed var(--vd-line); + border-radius: var(--vd-radius-sm); + display: grid; + place-items: center; + color: var(--vd-ink-muted); + font-size: 10px; + text-align: center; + padding: 12px; +} + +.studio-bottom-grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 12px; margin-top: 14px; } +.work-panel { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + min-width: 0; +} +.work-panel-header { + min-height: 44px; + padding: 0 13px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--vd-line); +} +.work-panel-header > div { display: flex; align-items: center; gap: 8px; } +.work-panel-header h3 { font-size: 12px; letter-spacing: -0.02em; } +.panel-icon { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 800; } +.panel-icon-warning { color: var(--vd-danger); border: 1px solid var(--vd-danger); } +.panel-count { min-width: 22px; height: 20px; border-radius: 10px; background: var(--vd-surface-muted); display: grid; place-items: center; font-size: 10px; font-weight: 700; color: var(--vd-ink-muted); } +.text-action { border: 0; background: transparent; color: var(--vd-brand); font-size: 10px; cursor: pointer; } +.attention-list, .activity-list { padding: 5px 12px; } +.attention-item { + min-height: 47px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border-bottom: 1px solid var(--vd-line); +} +.attention-item:last-child, .activity-item:last-child { border-bottom: 0; } +.attention-thumb { width: 34px; height: 34px; border-radius: 5px; background: var(--vd-surface-muted); object-fit: cover; } +.attention-copy { min-width: 0; } +.attention-title { font-size: 10px; font-weight: 680; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-meta { margin-top: 2px; font-size: 9px; color: var(--vd-ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attention-action { height: 26px; padding: 0 9px; border: 1px solid var(--vd-line); border-radius: 6px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 9px; font-weight: 650; cursor: pointer; } +.attention-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.activity-item { min-height: 39px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid var(--vd-line); } +.activity-icon { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 9px; } +.activity-copy { font-size: 10px; color: var(--vd-ink-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-time { font-size: 9px; color: var(--vd-ink-muted); white-space: nowrap; } + +.studio-inspector { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + scrollbar-width: thin; +} +.inspector-empty { min-height: 520px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 28px; color: var(--vd-ink-muted); } +.inspector-empty-mark { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); margin-bottom: 14px; } +.inspector-empty h3 { color: var(--vd-ink); font-size: 14px; } +.inspector-empty p { margin-top: 7px; max-width: 210px; font-size: 11px; line-height: 1.5; } +.inspector-content { padding: 10px; } +.inspector-hero { aspect-ratio: 1.42; border-radius: 7px; overflow: hidden; background: var(--vd-surface-muted); } +.inspector-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } +.inspector-thumbs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin-top: 6px; } +.inspector-thumbs img { width: 100%; aspect-ratio: 1; border-radius: 5px; object-fit: cover; background: var(--vd-surface-muted); } +.inspector-title-row { padding: 14px 2px 10px; } +.inspector-title-row h3 { font-size: 15px; line-height: 1.35; letter-spacing: -0.025em; } +.inspector-sku { margin-top: 5px; color: var(--vd-ink-muted); font: 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.inspector-price-row { display: flex; align-items: center; gap: 8px; padding: 0 2px 12px; } +.inspector-platform { font-size: 10px; font-weight: 800; text-transform: uppercase; } +.inspector-price { margin-left: auto; font-size: 18px; font-weight: 760; letter-spacing: -0.04em; } +.inspector-card { margin-top: 8px; padding: 12px; border: 1px solid var(--vd-line); border-radius: 7px; } +.inspector-card h4 { margin-bottom: 9px; font-size: 10px; color: var(--vd-ink-secondary); } +.confidence-row { display: flex; align-items: center; gap: 10px; } +.confidence-ring { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--vd-success) var(--confidence), var(--vd-surface-muted) 0); position: relative; } +.confidence-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface-raised); } +.confidence-ring span { position: relative; z-index: 1; font-size: 10px; font-weight: 780; } +.confidence-copy strong { display: block; color: var(--vd-success); font-size: 10px; } +.confidence-copy span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.4; } +.attribute-list { display: grid; grid-template-columns: 72px 1fr; gap: 6px 9px; font-size: 9px; } +.attribute-list dt { color: var(--vd-ink-muted); } +.attribute-list dd { color: var(--vd-ink-secondary); overflow-wrap: anywhere; } +.price-range { font-size: 15px; font-weight: 730; letter-spacing: -0.03em; } +.recommended-price { display: flex; justify-content: space-between; margin-top: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.recommended-price strong { color: var(--vd-ink); font-size: 12px; } +.inspector-actions { display: grid; gap: 6px; margin-top: 9px; } +.inspector-action { min-height: 36px; border: 1px solid var(--vd-line); border-radius: 7px; background: var(--vd-surface-raised); color: var(--vd-ink-secondary); font-size: 11px; font-weight: 650; cursor: pointer; } +.inspector-action:hover { border-color: var(--vd-brand); color: var(--vd-brand); } +.inspector-action.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: white; } +.inspector-action.primary:hover { background: var(--vd-brand-strong); color: white; } + +/* Legacy component refinement */ +.dropzone, .meta-section, .result-section, .chart-card, .stat-card, .admin-card, .settings-card { + box-shadow: none; +} +input, select, textarea, .model-select, .result-input, .result-textarea, .meta-input { + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +input:focus, select:focus, textarea:focus, .model-select:focus, .result-input:focus, .result-textarea:focus, .meta-input:focus { + border-color: var(--vd-brand); + box-shadow: 0 0 0 3px var(--vd-focus); +} +.primary-btn { background: var(--vd-brand); box-shadow: none; } +.primary-btn:hover { background: var(--vd-brand-strong); } +.secondary-btn, .small-btn { background: var(--vd-surface-raised); border-color: var(--vd-line); } + +@media (max-width: 1180px) { + .studio-layout { grid-template-columns: minmax(0, 1fr); } + .studio-inspector { position: static; max-height: none; } + .inspector-empty { min-height: 180px; } + .inspector-content { display: grid; grid-template-columns: minmax(240px, .85fr) 1.15fr; gap: 12px; } + .inspector-content > * { min-width: 0; } +} + +@media (max-width: 860px) { + :root { --vd-sidebar-width: 148px; } + .command-search { width: min(320px, 38vw); } + .topbar-kicker { display: none; } + .studio-bottom-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .main-wrapper { margin-left: 0; } + .topbar { padding: 0 12px; gap: 8px; } + .topbar-context { min-width: 0; } + .topbar-context-icon { display: none; } + .command-search { flex: 1; width: auto; } + .command-search span { display: none; } + .command-search kbd { display: none; } + .topbar-icon-btn, .topbar-avatar { display: none; } + main { padding: 16px 14px 24px; } + .studio-heading { align-items: flex-start; flex-direction: column; } + .flow-board { min-width: 940px; } + .inspector-content { display: block; } +} + +@media (max-width: 520px) { + .topbar-btn.primary { width: 36px; padding: 0; justify-content: center; } + .topbar-btn.primary span { display: none; } + .topbar-title { max-width: 112px; overflow: hidden; text-overflow: ellipsis; } + .page-heading h2 { font-size: 20px; } + .studio-summary { flex-wrap: wrap; } +} + +/* ========================================================================== + Listings / Historie 2026 + ========================================================================== */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.listings-page { min-width: 0; } +.listings-heading { align-items: flex-start; } +.listings-heading-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} +.vd-button { + min-height: 36px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 11px; + font-weight: 650; + cursor: pointer; + transition: border-color .15s ease, color .15s ease, background .15s ease; + white-space: nowrap; +} +.vd-button:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.vd-button.primary { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.vd-button.primary:hover:not(:disabled) { background: var(--vd-brand-strong); border-color: var(--vd-brand-strong); color: #fff; } +.vd-button.ghost { background: transparent; } +.vd-button.compact { min-height: 31px; padding: 0 10px; font-size: 10px; } +.vd-button.danger { color: var(--vd-danger); } +.vd-button:disabled { opacity: .38; cursor: not-allowed; } + +.history-export-wrap, +.history-columns-wrap { position: relative; } +.history-popover { + position: absolute; + z-index: 45; + top: calc(100% + 7px); + right: 0; + min-width: 190px; + padding: 7px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} +.history-export-menu button, +.history-row-menu button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vd-ink-secondary); + padding: 0 10px; + text-align: left; + font-size: 11px; + cursor: pointer; +} +.history-export-menu button:hover, +.history-row-menu button:hover { background: var(--vd-surface-muted); color: var(--vd-ink); } +.history-row-menu button.danger { color: var(--vd-danger); } +.history-columns-panel { min-width: 208px; padding: 11px; } +.history-columns-panel strong { display: block; margin-bottom: 8px; font-size: 11px; } +.history-columns-panel label { + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + color: var(--vd-ink-secondary); + font-size: 11px; + cursor: pointer; +} +.history-columns-panel input { accent-color: var(--vd-brand); } + +.history-filterbar { + display: grid; + grid-template-columns: minmax(250px, 1.45fr) repeat(3, minmax(135px, .65fr)) auto auto; + gap: 9px; + align-items: end; + margin-bottom: 14px; +} +.history-search-field, +.history-filter-field, +.history-custom-dates label { + min-height: 52px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); +} +.history-search-field { + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + color: var(--vd-ink-muted); +} +.history-search-field:focus-within, +.history-filter-field:focus-within, +.history-custom-dates label:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.history-search-field input { + width: 100%; + min-width: 0; + height: 48px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 12px; +} +.history-search-field input::placeholder { color: var(--vd-ink-muted); } +.history-filter-field, +.history-custom-dates label { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 6px 11px; +} +.history-filter-field > span, +.history-custom-dates label > span { + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 650; +} +.history-filter-field select, +.history-custom-dates input { + width: 100%; + min-height: 23px; + padding: 0 20px 0 0; + border: 0; + outline: 0; + background: transparent; + color: var(--vd-ink); + font-size: 11px; + font-weight: 600; +} +.history-custom-dates { display: grid; grid-template-columns: repeat(2, 125px); gap: 7px; } +.history-filterbar > .vd-button { min-height: 52px; } + +.history-table-shell { + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + overflow: visible; +} +.history-batch-bar { + min-height: 49px; + margin: 0; + padding: 8px 12px; + border: 0; + border-bottom: 1px solid var(--vd-line); + border-radius: var(--vd-radius-md) var(--vd-radius-md) 0 0; + box-shadow: none; + background: var(--vd-surface-raised); + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; + overflow-x: auto; +} +.history-batch-bar.has-selection { background: color-mix(in srgb, var(--vd-brand-soft) 65%, var(--vd-surface-raised)); } +.history-select-all-wrap { + flex: 0 0 auto; + min-width: 112px; + min-height: 31px; + padding: 0 9px; + border-right: 1px solid var(--vd-line); + color: var(--vd-ink-secondary); +} +.history-select-all-wrap input, +.history-row-select { width: 15px; height: 15px; accent-color: var(--vd-brand); cursor: pointer; } +.history-batch-actions { display: flex; align-items: center; gap: 6px; } +.history-clear-selection { + flex: 0 0 auto; + margin-left: auto; + border: 0; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + cursor: pointer; +} +.history-clear-selection:hover { color: var(--vd-brand); } + +.history-table { + --history-platform: 104px; + --history-provider: 118px; + --history-price: 105px; + --history-status: 125px; + --history-location: 135px; + --history-published: 150px; + min-width: 1080px; + overflow: visible; +} +.history-table-shell { overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--vd-line-strong) transparent; } +.history-table-head, +.history-row { + display: grid; + grid-template-columns: + 34px + minmax(270px, 1.8fr) + var(--history-platform) + var(--history-provider) + var(--history-price) + var(--history-status) + var(--history-location) + var(--history-published) + 40px; + align-items: center; +} +.history-table-head { + min-height: 38px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 680; + letter-spacing: .018em; +} +.history-list { display: block; } +.history-row { + min-height: 70px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface-raised); + transition: background .15s ease; + position: relative; +} +.history-row:last-child { border-bottom: 0; } +.history-row:hover { background: color-mix(in srgb, var(--vd-surface-muted) 68%, transparent); } +.history-row.selected { background: color-mix(in srgb, var(--vd-brand-soft) 72%, var(--vd-surface-raised)); } +.history-cell { + min-width: 0; + padding: 8px 10px; + color: var(--vd-ink-secondary); + font-size: 10px; +} +.history-cell-select { padding-left: 14px; padding-right: 4px; } +.history-cell-article { display: flex; align-items: center; gap: 11px; } +.history-thumb { + width: 56px; + height: 48px; + flex: 0 0 auto; + border-radius: 6px; + object-fit: cover; + background: var(--vd-surface-muted); +} +.history-thumb-placeholder { display: grid; place-items: center; color: var(--vd-ink-muted); } +.history-article-copy { min-width: 0; } +.history-article-title { + display: block; + max-width: 100%; + border: 0; + background: transparent; + color: var(--vd-ink); + padding: 0; + font-size: 11px; + font-weight: 700; + line-height: 1.35; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.history-article-title:hover { color: var(--vd-brand); } +.history-article-sku { + margin-top: 4px; + color: var(--vd-ink-muted); + font: 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-cell strong { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.history-cell-sub, +.history-cell small { + display: block; + margin-top: 3px; + color: var(--vd-ink-muted); + font-size: 8.5px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.history-platform-name { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 760; } +.history-provider-mark { + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--vd-success-soft); + color: var(--vd-success); + font-size: 9px; + font-weight: 800; +} +.history-cell-provider { display: flex; align-items: center; gap: 7px; } +.history-cell-provider span:last-child { min-width: 0; } +.history-status-select { + max-width: 96px; + min-height: 23px; + border: 0; + border-radius: 5px; + padding: 0 20px 0 7px; + font-size: 9px; + font-weight: 720; + cursor: pointer; +} +.history-status-select.status-active { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.history-status-select.status-reserved { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-date { display: block; color: var(--vd-ink-secondary); font-size: 9px; white-space: nowrap; } +.history-publish-badges { display: flex; gap: 4px; margin-top: 4px; min-width: 0; } +.history-publish-badge { + max-width: 100%; + min-height: 18px; + padding: 2px 6px; + border-radius: 5px; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vd-ink-secondary); + background: var(--vd-surface-muted); + font-size: 8px; + font-weight: 680; + white-space: nowrap; +} +.history-publish-badge span { color: var(--vd-ink); font-weight: 780; } +.history-publish-badge.published { background: var(--vd-success-soft); color: var(--vd-success); } +.history-publish-badge.copied { background: var(--vd-info-soft); color: var(--vd-info); } +.history-publish-badge.pending { background: var(--vd-warning-soft); color: var(--vd-warning); } +.history-publish-badge.failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.history-publish-empty { color: var(--vd-ink-muted); font-size: 8.5px; white-space: nowrap; } +.history-cell-menu { position: relative; padding-left: 4px; padding-right: 8px; } +.history-row-menu-button { + width: 30px; + height: 30px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); + font-size: 10px; + letter-spacing: 1px; + cursor: pointer; +} +.history-row-menu-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-row-menu { + position: absolute; + z-index: 35; + top: 31px; + right: 8px; + width: 145px; + padding: 6px; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + box-shadow: var(--vd-shadow-float); +} + +.history-hide-platform { --history-platform: 0px; } +.history-hide-provider { --history-provider: 0px; } +.history-hide-price { --history-price: 0px; } +.history-hide-status { --history-status: 0px; } +.history-hide-location { --history-location: 0px; } +.history-hide-published { --history-published: 0px; } +.history-hide-platform [data-column="platform"], +.history-hide-provider [data-column="provider"], +.history-hide-price [data-column="price"], +.history-hide-status [data-column="status"], +.history-hide-location [data-column="location"], +.history-hide-published [data-column="published"] { display: none; padding: 0; } + +.history-table-footer { + min-height: 55px; + padding: 9px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--vd-line); +} +.history-page-size { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 10px; } +.history-page-size select { + min-height: 30px; + padding: 0 25px 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + font-size: 10px; +} +.history-pagination-nav { display: flex; align-items: center; gap: 5px; } +.history-page-buttons { display: flex; align-items: center; gap: 4px; } +.history-page-button, +.history-pagination-nav .page-nav-btn { + min-width: 31px; + height: 31px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 10px; + cursor: pointer; +} +.history-page-button:hover:not(.active), +.history-pagination-nav .page-nav-btn:hover:not(:disabled) { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.history-page-button.active { background: var(--vd-brand); border-color: var(--vd-brand); color: #fff; } +.history-pagination-nav .page-nav-btn:disabled { opacity: .3; cursor: not-allowed; } +.history-page-gap { width: 20px; text-align: center; color: var(--vd-ink-muted); font-size: 10px; } +.history-empty-state { + min-height: 360px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 36px; +} +.history-empty-icon { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--vd-surface-muted); color: var(--vd-ink-muted); } +.history-empty-state h3 { margin-top: 13px; font-size: 14px; } +.history-empty-state p { margin: 5px 0 14px; color: var(--vd-ink-muted); font-size: 11px; } + +/* Detail stays functional while receiving the new surface language. */ +.listings-page .history-detail { + max-width: 1180px; + margin: 0 auto; + border-color: var(--vd-line); + border-radius: var(--vd-radius-md); + background: var(--vd-surface-raised); + box-shadow: none; +} + +@media (max-width: 1180px) { + .history-filterbar { grid-template-columns: minmax(230px, 1.3fr) repeat(3, minmax(125px, .7fr)); } + .history-filterbar > .vd-button { grid-column: auto; } + .history-custom-dates { grid-column: 1 / -1; justify-content: start; } + .history-table { min-width: 980px; --history-provider: 0px; --history-location: 0px; } + .history-table [data-column="provider"], + .history-table [data-column="location"] { display: none; } +} + +@media (max-width: 860px) { + .listings-heading { flex-direction: column; } + .listings-heading-actions { width: 100%; } + .history-filterbar { grid-template-columns: 1fr 1fr; } + .history-search-field { grid-column: 1 / -1; } + .history-filterbar > .vd-button { min-height: 42px; } + .history-batch-bar { align-items: flex-start; } + .history-batch-actions { min-width: max-content; } +} + +@media (max-width: 520px) { + .listings-heading-actions { overflow-x: auto; padding-bottom: 2px; } + .history-filterbar { grid-template-columns: 1fr; } + .history-search-field, + .history-filter-field { min-height: 48px; } + .history-search-field { grid-column: auto; } + .history-custom-dates { grid-template-columns: 1fr 1fr; } + .history-table-footer { align-items: flex-start; flex-direction: column; } + .history-pagination-nav { width: 100%; justify-content: space-between; } + .history-page-buttons { overflow-x: auto; } +} + +/* --- Slice 03: Studio media fit + in-app Print Studio --- */ +.flow-card-media, +.inspector-hero, +.attention-thumb { + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.flow-card-media { + display: grid; + place-items: center; + padding: 7px; +} +.flow-card-media img, +.inspector-hero img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} +.inspector-hero { + display: grid; + place-items: center; + padding: 12px; +} +.attention-thumb { + object-fit: contain; + object-position: center; + padding: 3px; +} + +body.print-studio-open { overflow: hidden; } +.print-studio { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + padding: 22px; +} +.print-studio-backdrop { + position: absolute; + inset: 0; + background: rgba(22, 22, 19, 0.56); + backdrop-filter: blur(9px); +} +.print-studio-shell { + position: relative; + width: min(1380px, 96vw); + height: min(900px, 94vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 18px; + background: var(--vd-surface-raised); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); +} +.print-studio-header, +.print-studio-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 17px 20px; + border-color: var(--vd-line); + background: var(--vd-surface-raised); +} +.print-studio-header { border-bottom: 1px solid var(--vd-line); } +.print-studio-footer { border-top: 1px solid var(--vd-line); justify-content: flex-end; } +.print-studio-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-brand); + font-size: 9px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.print-studio-header h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; } +.print-studio-close { + width: 36px; + height: 36px; + border: 1px solid var(--vd-line); + border-radius: 50%; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 23px; + line-height: 1; + cursor: pointer; +} +.print-studio-close:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.print-studio-layout { + min-height: 0; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); +} +.print-studio-controls { + padding: 20px; + overflow-y: auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.print-control-group { + display: flex; + align-items: center; + gap: 11px; + padding: 13px; + margin-bottom: 20px; + border: 1px solid var(--vd-line); + border-radius: 11px; + background: var(--vd-surface-raised); +} +.print-control-group > div { min-width: 0; display: grid; gap: 2px; } +.print-control-group strong { font-size: 13px; } +.print-control-group span:not(.print-summary-icon) { color: var(--vd-ink-muted); font-size: 10px; } +.print-summary-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 9px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 18px; + font-weight: 800; +} +.print-control-field { display: grid; gap: 7px; margin-bottom: 16px; } +.print-control-field > span, +.print-control-check > span { color: var(--vd-ink-secondary); font-size: 10px; font-weight: 700; } +.print-control-field select, +.print-control-field input { + width: 100%; + min-height: 40px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + padding: 0 11px; + font: inherit; + font-size: 11px; +} +.print-control-field select:focus, +.print-control-field input:focus { outline: 2px solid var(--vd-brand-soft); border-color: var(--vd-brand); } +.print-control-check { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 0; + cursor: pointer; +} +.print-control-check input { width: 16px; height: 16px; accent-color: var(--vd-brand); } +.print-hint { + display: grid; + gap: 5px; + margin-top: 24px; + padding: 13px; + border-left: 3px solid var(--vd-brand); + background: var(--vd-brand-soft); +} +.print-hint strong { font-size: 11px; } +.print-hint span { color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.5; } +.print-preview-pane { + min-width: 0; + overflow: auto; + padding: 0 24px 36px; + background: + linear-gradient(90deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + linear-gradient(0deg, transparent 23px, rgba(127, 122, 112, .08) 24px, transparent 25px), + var(--vd-surface-muted); + background-size: 24px 24px; +} +.print-preview-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 -24px 24px; + padding: 12px 24px; + border-bottom: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-muted) 90%, transparent); + backdrop-filter: blur(10px); + color: var(--vd-ink-muted); + font-size: 10px; +} +.print-preview-toolbar span:first-child { color: var(--vd-ink); font-weight: 700; } +.print-preview-pages { display: grid; justify-items: center; gap: 28px; } +.print-page { + width: 794px; + min-height: 1123px; + padding: 38px; + overflow: hidden; + background: #fff; + color: #1f1f1c; + box-shadow: 0 14px 40px rgba(31, 31, 28, .18); +} +.print-label-grid { display: grid; width: 100%; height: 100%; align-content: start; } +.standard-labels .print-label-grid { grid-template-columns: repeat(2, 340px); grid-auto-rows: 177px; gap: 23px 34px; } +.compact-labels .print-label-grid { grid-template-columns: repeat(3, 217px); grid-auto-rows: 101px; gap: 8px 18px; } +.print-label-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 15px; + overflow: hidden; + border: 1px solid #d8d5ce; + background: #fff; + padding: 15px 16px 15px 21px; +} +.print-label-accent { position: absolute; inset: 0 auto 0 0; width: 5px; background: #e33a16; } +.print-label-qr img { display: block; width: 106px; height: 106px; } +.print-label-copy { min-width: 0; display: grid; align-content: center; gap: 4px; } +.print-label-brand { color: #e33a16; font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.print-label-sku { font-size: 21px; font-weight: 850; letter-spacing: -.035em; } +.print-label-title { min-height: 31px; overflow: hidden; font-size: 11px; font-weight: 650; line-height: 1.35; } +.print-label-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #6d6961; font-size: 9px; } +.print-label-meta strong { color: #1f1f1c; font-size: 12px; } +.compact-labels .print-label-card { gap: 7px; padding: 7px 8px 7px 11px; } +.compact-labels .print-label-accent { width: 3px; } +.compact-labels .print-label-qr img { width: 62px; height: 62px; } +.compact-labels .print-label-brand { font-size: 6px; } +.compact-labels .print-label-sku { font-size: 12px; } +.compact-labels .print-label-title { min-height: 20px; font-size: 7px; } +.compact-labels .print-label-meta { font-size: 6px; } +.compact-labels .print-label-meta strong { font-size: 8px; } +.print-datasheet { min-height: 100%; display: grid; align-content: start; gap: 24px; } +.print-datasheet-header { display: flex; justify-content: space-between; gap: 30px; padding-bottom: 22px; border-bottom: 4px solid #e33a16; } +.print-datasheet-brand { display: block; margin-bottom: 7px; color: #e33a16; font-size: 10px; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; } +.print-datasheet h1 { max-width: 570px; margin: 0; font-size: 29px; line-height: 1.15; letter-spacing: -.04em; } +.print-datasheet-sku { margin-top: 9px; color: #5f5b54; font-size: 16px; font-weight: 750; } +.print-datasheet-qr { width: 118px; height: 118px; } +.print-datasheet-photos { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.print-datasheet-photos > div { aspect-ratio: 1; display: grid; place-items: center; border: 1px solid #dfdcd5; background: #f5f3ee; padding: 8px; } +.print-datasheet-photos img { width: 100%; height: 100%; object-fit: contain; } +.print-datasheet-description { padding: 17px 19px; border-left: 4px solid #e33a16; background: #f5f3ee; } +.print-datasheet-description h2 { margin: 0 0 7px; font-size: 13px; } +.print-datasheet-description p { margin: 0; color: #45423d; font-size: 11px; line-height: 1.65; } +.print-datasheet-meta { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid #dfdcd5; } +.print-datasheet-meta > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; padding: 11px 0; border-bottom: 1px solid #dfdcd5; } +.print-datasheet-meta > div:nth-child(odd) { padding-right: 20px; } +.print-datasheet-meta > div:nth-child(even) { padding-left: 20px; border-left: 1px solid #dfdcd5; } +.print-datasheet-meta span { color: #77726a; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } +.print-datasheet-meta strong { font-size: 11px; } + +@media (max-width: 1050px) { + .print-studio { padding: 10px; } + .print-studio-shell { width: 100%; height: 100%; border-radius: 12px; } + .print-studio-layout { grid-template-columns: 220px minmax(0, 1fr); } + .print-page { transform: scale(.78); transform-origin: top center; margin-bottom: -247px; } +} + +@media (max-width: 720px) { + .print-studio-layout { grid-template-columns: 1fr; } + .print-studio-controls { border-right: 0; border-bottom: 1px solid var(--vd-line); max-height: 220px; } + .print-preview-pane { min-height: 0; } + .print-page { transform: scale(.55); margin-bottom: -505px; } +} + +@media print { + @page { size: A4 portrait; margin: 0; } + body.print-studio-active { display: block !important; background: #fff !important; } + body.print-studio-active > *:not(#print-studio) { display: none !important; } + body.print-studio-active #print-studio { + position: static !important; + display: block !important; + padding: 0 !important; + background: #fff !important; + } + body.print-studio-active .print-studio-backdrop, + body.print-studio-active .print-studio-header, + body.print-studio-active .print-studio-controls, + body.print-studio-active .print-preview-toolbar, + body.print-studio-active .print-studio-footer { display: none !important; } + body.print-studio-active .print-studio-shell, + body.print-studio-active .print-studio-layout, + body.print-studio-active .print-preview-pane, + body.print-studio-active .print-preview-pages { + display: block !important; + width: auto !important; + height: auto !important; + overflow: visible !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + box-shadow: none !important; + background: #fff !important; + } + body.print-studio-active .print-page { + width: 210mm !important; + min-height: 297mm !important; + padding: 10mm !important; + margin: 0 !important; + transform: none !important; + box-shadow: none !important; + break-after: page; + page-break-after: always; + } + body.print-studio-active .print-page:last-child { break-after: auto; page-break-after: auto; } + body.print-studio-active .standard-labels .print-label-grid { + grid-template-columns: repeat(2, 90mm) !important; + grid-auto-rows: 50mm !important; + gap: 5mm 5mm !important; + } + body.print-studio-active .compact-labels .print-label-grid { + grid-template-columns: repeat(3, 62mm) !important; + grid-auto-rows: 29mm !important; + gap: 1.6mm 2mm !important; + } +} + +/* ========================================================================== + Listing Studio 2026 + ========================================================================== */ +.generator-studio-page { min-width: 0; } +.generator-heading { align-items: center; } +.generator-heading-actions { display: flex; align-items: center; gap: 12px; } +.generator-stepper { display: flex; align-items: center; gap: 6px; } +.generator-step { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 999px; + color: var(--vd-ink-muted); + background: var(--vd-surface-raised); + font-size: 10px; + font-weight: 680; + white-space: nowrap; +} +.generator-step b { + width: 17px; + height: 17px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 9px; +} +.generator-step.is-active { border-color: var(--vd-brand); color: var(--vd-brand); } +.generator-step.is-active b { background: var(--vd-brand); color: white; } +.generator-step.is-complete { border-color: var(--vd-success); color: var(--vd-success); } +.generator-step.is-complete b { background: var(--vd-success); color: white; } + +.generator-studio-layout { + display: grid; + grid-template-columns: minmax(260px, .82fr) minmax(310px, 1fr) minmax(350px, 1.18fr); + gap: 12px; + align-items: start; +} +.generator-panel { + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: var(--vd-radius-sm); + background: var(--vd-surface-raised); + overflow: hidden; +} +.generator-panel-header { + min-height: 58px; + padding: 11px 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--vd-line); +} +.generator-panel-header > div { display: flex; align-items: center; gap: 9px; min-width: 0; } +.generator-panel-header h3 { font-size: 12px; letter-spacing: -0.025em; } +.generator-panel-header p { margin-top: 2px; color: var(--vd-ink-muted); font-size: 9px; line-height: 1.35; } +.generator-panel-index { + width: 27px; + height: 27px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; + background: var(--vd-surface-muted); + color: var(--vd-brand); + font: 720 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.generator-count, .generator-beta, .generator-preview-status { + min-height: 22px; + padding: 3px 8px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 9px; + font-weight: 720; + white-space: nowrap; +} +.generator-beta { background: var(--vd-brand-soft); color: var(--vd-brand); } +.generator-preview-status.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.generator-preview-status.is-working { background: var(--vd-warning-soft); color: var(--vd-warning); } + +.media-workbench { padding-bottom: 12px; } +.generator-photo-stage { + position: relative; + aspect-ratio: 1.2; + margin: 12px 12px 9px; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: + linear-gradient(45deg, var(--vd-surface-muted) 25%, transparent 25%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 0 0/18px 18px, + linear-gradient(45deg, transparent 75%, var(--vd-surface-muted) 75%) 9px -9px/18px 18px, + linear-gradient(45deg, var(--vd-surface-muted) 25%, var(--vd-surface) 25%) 9px -9px/18px 18px; +} +.generator-cover { width: 100%; height: 100%; display: block; object-fit: contain; object-position: center; padding: 8px; } +.generator-photo-empty { + width: 100%; + height: 100%; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 26px; + color: var(--vd-ink-muted); +} +.generator-photo-empty-mark { + width: 58px; + height: 58px; + display: grid; + place-items: center; + margin-bottom: 12px; + border-radius: 50%; + background: var(--vd-surface-raised); + border: 1px solid var(--vd-line); + color: var(--vd-brand); +} +.generator-photo-empty strong { color: var(--vd-ink); font-size: 12px; } +.generator-photo-empty span { max-width: 220px; margin-top: 6px; font-size: 10px; line-height: 1.5; } +.generator-cover-badge { + position: absolute; + left: 9px; + bottom: 9px; + min-height: 23px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--vd-ink) 82%, transparent); + color: var(--vd-surface); + font-size: 9px; + font-weight: 720; + backdrop-filter: blur(8px); +} +.generator-dropzone { + margin: 0 12px; + min-height: 92px; + padding: 13px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + text-align: left; + border-width: 1px; + border-radius: 8px; + background: var(--vd-surface); +} +.generator-dropzone-icon { + grid-row: 1 / 3; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + background: var(--vd-brand-soft); + color: var(--vd-brand); +} +.generator-dropzone p { font-size: 10px; line-height: 1.45; color: var(--vd-ink-secondary); } +.generator-dropzone p strong { color: var(--vd-ink); font-size: 11px; } +.generator-dropzone small { color: var(--vd-ink-muted); font-size: 8px; } +.generator-photo-strip { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin: 9px 12px 0; +} +.generator-photo-strip .thumb { + border: 1px solid var(--vd-line); + border-radius: 6px; + box-shadow: none; + background: var(--vd-surface-muted); +} +.generator-photo-strip .thumb:first-child { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.generator-photo-strip .thumb img { object-fit: contain; object-position: center; padding: 2px; } +.generator-photo-strip .thumb::after { + content: attr(data-index); + position: absolute; + left: 4px; + bottom: 4px; + min-width: 15px; + height: 15px; + display: grid; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--vd-ink) 76%, transparent); + color: var(--vd-surface); + font-size: 7px; + font-weight: 760; +} +.generator-image-tools { margin: 8px 12px 0; } +.generator-image-tools .small-btn { display: inline-flex; align-items: center; gap: 6px; } +.generator-image-tools .advanced-editor-entry { + background: var(--vd-brand); + color: #fff; + border-color: var(--vd-brand); + font-weight: 760; + box-shadow: 0 5px 14px color-mix(in srgb, var(--vd-brand) 22%, transparent); +} +.generator-image-tools .advanced-editor-entry:hover { + background: var(--vd-brand-strong, var(--vd-brand)); + color: #fff; + transform: translateY(-1px); +} + +.generator-photo-quality { + margin: 11px 12px 0; + padding: 11px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-photo-quality-head { display: flex; align-items: center; justify-content: space-between; } +.generator-photo-quality-head > div { display: flex; align-items: center; gap: 6px; } +.generator-photo-quality-head strong { font-size: 10px; } +.generator-photo-quality-head > span { color: var(--vd-ink-muted); font-size: 9px; font-weight: 700; } +.quality-spark { color: var(--vd-brand); } +.generator-photo-quality ul { list-style: none; margin-top: 8px; display: grid; gap: 6px; } +.generator-photo-quality li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-muted); font-size: 9px; } +.generator-photo-quality li > span { + width: 13px; + height: 13px; + border: 1px solid var(--vd-line-strong); + border-radius: 50%; + flex: 0 0 auto; +} +.generator-photo-quality li.is-complete { color: var(--vd-ink-secondary); } +.generator-photo-quality li.is-complete > span { border-color: var(--vd-success); background: var(--vd-success); position: relative; } +.generator-photo-quality li.is-complete > span::after { content: "✓"; position: absolute; inset: 0; display: grid; place-items: center; color: white; font-size: 8px; } + +.generator-config-panel { position: relative; } +.generator-config-stack { padding: 10px; display: grid; gap: 7px; } +.generator-config-card, .generator-notes-card { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-config-card { display: grid; gap: 8px; } +.generator-config-card.compact { align-content: start; } +.generator-config-copy label, .generator-notes-head label { display: block; color: var(--vd-ink); font-size: 10px; font-weight: 720; text-transform: none; letter-spacing: 0; margin: 0; } +.generator-config-copy span, .generator-notes-head span { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.4; } +.generator-choice-group { gap: 5px; } +.generator-choice-group .toggle-btn { + min-height: 28px; + padding: 0 9px; + border-width: 1px; + border-radius: 7px; + font-size: 9px; +} +.generator-choice-group .toggle-btn.active { background: var(--vd-brand); border-color: var(--vd-brand); } +.generator-config-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.generator-config-card select { min-height: 34px; padding: 6px 9px; font-size: 10px; } +.generator-template-tags { display: flex; gap: 4px; flex-wrap: wrap; } +.generator-template-tags span { + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 999px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 8px; + font-weight: 680; +} +.generator-notes-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.generator-notes-head > span { white-space: nowrap; } +.generator-notes-card textarea { + width: 100%; + margin-top: 8px; + min-height: 88px; + resize: vertical; + padding: 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + color: var(--vd-ink); + background: var(--vd-surface-raised); + font: 10px/1.55 inherit; +} +.generator-tone-row { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 7px; } +.generator-tone-row span { padding: 3px 6px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 7px; } +.generator-loading { margin: 0 10px 10px; padding: 18px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.generator-loading p { line-height: 1.5; } +.generator-loading p strong { color: var(--vd-ink); } +.generator-loading p span { font-size: 9px; } +.generator-action-zone { + position: sticky; + bottom: 0; + display: grid; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--vd-line); + background: color-mix(in srgb, var(--vd-surface-raised) 94%, transparent); + backdrop-filter: blur(10px); +} +.generator-readiness { display: grid; grid-template-columns: 9px minmax(0, 1fr); gap: 8px; align-items: center; } +.generator-readiness > span { width: 7px; height: 7px; border-radius: 50%; background: var(--vd-warning); } +.generator-readiness.is-ready > span { background: var(--vd-success); } +.generator-readiness strong { display: block; font-size: 9px; } +.generator-readiness small { display: block; margin-top: 2px; color: var(--vd-ink-muted); font-size: 8px; } +.generator-primary-action { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 11px; } + +.generator-preview-panel { + position: sticky; + top: calc(var(--vd-topbar-height) + 20px); + max-height: calc(100vh - var(--vd-topbar-height) - 40px); + overflow-y: auto; + scrollbar-width: thin; +} +.generator-preview-empty { min-height: 520px; display: grid; align-content: center; justify-items: center; text-align: center; padding: 34px; color: var(--vd-ink-muted); } +.generator-preview-empty-mark { + width: 62px; + height: 62px; + display: grid; + place-items: center; + border-radius: 18px 18px 18px 5px; + background: var(--vd-brand); + color: white; + font-size: 17px; + font-weight: 820; + letter-spacing: -.08em; + box-shadow: 0 12px 24px var(--vd-brand-soft); +} +.generator-preview-empty h3 { margin-top: 15px; color: var(--vd-ink); font-size: 14px; } +.generator-preview-empty p { max-width: 275px; margin-top: 7px; font-size: 10px; line-height: 1.55; } +.generator-preview-skeleton { width: min(280px, 100%); margin-top: 20px; display: grid; gap: 7px; } +.generator-preview-skeleton span { height: 9px; border-radius: 999px; background: var(--vd-surface-muted); } +.generator-preview-skeleton span:nth-child(2) { width: 82%; } +.generator-preview-skeleton span:nth-child(3) { width: 94%; } +.generator-preview-skeleton span:nth-child(4) { width: 58%; } +.generator-variant-wrap { padding: 10px 12px 0; } +.generator-section-label { color: var(--vd-ink-muted); font-size: 8px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.generator-result { margin: 0; padding: 12px; border: 0; border-radius: 0; background: transparent; } +.generator-result-score { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); +} +.generator-result-score > div:first-child span { display: block; color: var(--vd-ink-muted); font-size: 8px; } +.generator-result-score > div:first-child strong { display: block; margin-top: 2px; color: var(--vd-success); font-size: 11px; } +.generator-quality-ring { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 50%; + background: conic-gradient(var(--vd-success) var(--quality), var(--vd-surface-muted) 0); + position: relative; +} +.generator-quality-ring::after { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--vd-surface); } +.generator-quality-ring span { position: relative; z-index: 1; font-size: 9px; font-weight: 780; } +.generator-result .result-field { margin-bottom: 11px; } +.generator-result .field-header > div { display: flex; align-items: center; gap: 6px; } +.generator-result .field-header label { color: var(--vd-ink-secondary); font-size: 9px; } +.field-count { color: var(--vd-ink-muted); font-size: 8px; } +.generator-result .result-input { padding: 8px 9px; font-size: 11px; } +.generator-result .ql-toolbar.ql-snow { border-color: var(--vd-line); border-radius: 7px 7px 0 0; background: var(--vd-surface); } +.generator-result .ql-container.ql-snow { min-height: 190px; border-color: var(--vd-line); border-radius: 0 0 7px 7px; font-size: 11px; } +.generator-meta-section { margin: 12px 0 0; padding: 10px; border-radius: 8px; background: var(--vd-surface); } +.generator-meta-section .meta-grid-edit { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 8px; } +.generator-meta-section .meta-category-item { grid-column: 1 / -1; } +.generator-meta-section .meta-label { font-size: 8px; } +.generator-meta-section .meta-input { padding: 7px 8px; font-size: 10px; } +.generator-meta-section .fee-info { padding-top: 8px; font-size: 9px; } +.generator-meta-section .fee-info .fee-net { font-size: 11px; } +.generator-html-details { margin-top: 11px; border: 1px solid var(--vd-line); border-radius: 8px; overflow: hidden; } +.generator-html-details summary { padding: 10px; cursor: pointer; color: var(--vd-ink-secondary); font-size: 9px; font-weight: 680; } +.generator-html-details summary span { color: var(--vd-ink-muted); font-weight: 500; } +.generator-html-actions { display: flex; justify-content: flex-end; gap: 5px; padding: 0 10px 8px; } +.generator-html-details .html-preview, .generator-html-details .html-source { margin: 0 10px 10px; width: calc(100% - 20px); } +.generator-result-actions { position: sticky; bottom: -12px; display: grid; grid-template-columns: auto 1fr; padding: 10px 0 0; background: var(--vd-surface-raised); } +.generator-result-actions button { min-height: 38px; } + +@media (max-width: 1280px) { + .generator-studio-layout { grid-template-columns: minmax(250px, .8fr) minmax(310px, 1fr); } + .generator-preview-panel { grid-column: 1 / -1; position: static; max-height: none; } + .generator-preview-empty { min-height: 260px; } +} + +@media (max-width: 900px) { + .generator-heading { align-items: flex-start; flex-direction: column; } + .generator-heading-actions { width: 100%; justify-content: space-between; } + .generator-studio-layout { grid-template-columns: 1fr; } + .generator-preview-panel { grid-column: auto; } +} + +@media (max-width: 560px) { + .generator-stepper { width: 100%; } + .generator-step { flex: 1; justify-content: center; padding: 0 5px; } + .generator-heading-actions { align-items: stretch; flex-direction: column; } + .generator-config-pair { grid-template-columns: 1fr; } + .generator-photo-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .generator-meta-section .meta-grid-edit { grid-template-columns: 1fr; } + .generator-meta-section .meta-category-item { grid-column: auto; } +} + +/* --- Slice 05: Publish Center 2026 --- */ +.publish-center-page { padding-bottom: 22px; } +.publish-center-heading { align-items: flex-end; } +.publish-heading-actions { display: flex; align-items: center; gap: 7px; } +.publish-view-tabs { + display: flex; + align-items: flex-end; + gap: 2px; + margin: 10px 0 0; + padding: 0 2px; + overflow-x: auto; + border-bottom: 1px solid var(--vd-line); + scrollbar-width: none; +} +.publish-view-tabs::-webkit-scrollbar { display: none; } +.publish-view-tab { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--vd-ink-muted); + font-size: 10px; + font-weight: 680; + white-space: nowrap; + cursor: pointer; +} +.publish-view-tab:hover { color: var(--vd-ink); background: var(--vd-surface-muted); } +.publish-view-tab.active { color: var(--vd-ink); border-bottom-color: var(--vd-brand); background: var(--vd-surface-raised); } +.publish-tab-count, +.publish-tab-badge { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 5px; + border-radius: 999px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 8px; + font-weight: 760; +} +.publish-tab-badge { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-view-tab.active .publish-tab-count { background: var(--vd-brand-soft); color: var(--vd-brand); } + +.publish-center-grid { + min-height: 680px; + display: grid; + grid-template-columns: 264px minmax(0, 1fr) 282px; + border: 1px solid var(--vd-line); + border-top: 0; + border-radius: 0 0 var(--vd-radius-md) var(--vd-radius-md); + overflow: hidden; + background: var(--vd-surface-raised); +} +.publish-list-panel, +.publish-status-rail, +.publish-workspace-panel { min-width: 0; min-height: 0; } +.publish-list-panel { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + border-right: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-list-panel-head, +.publish-rail-heading, +.publish-operation-header, +.publish-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.publish-list-panel-head { padding: 14px 14px 11px; } +.publish-panel-kicker { + display: block; + margin-bottom: 2px; + color: var(--vd-ink-muted); + font-size: 7.5px; + font-weight: 780; + letter-spacing: .1em; + text-transform: uppercase; +} +.publish-list-panel-head h3, +.publish-rail-heading h3, +.publish-operation-header h3, +.publish-workspace-header h3 { margin: 0; color: var(--vd-ink); font-size: 13px; letter-spacing: -.015em; } +.publish-list-panel-head h3 span { color: var(--vd-ink-muted); font-weight: 560; } +.publish-list-panel-head select { + max-width: 112px; + min-height: 30px; + padding: 0 24px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-list-filters { padding: 0 12px 11px; display: grid; gap: 7px; border-bottom: 1px solid var(--vd-line); } +.publish-search-field { + min-height: 36px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink-muted); +} +.publish-search-field:focus-within { border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-focus); } +.publish-search-field input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--vd-ink); font: 9px/1 inherit; } +.publish-filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.publish-filter-row select { + min-width: 0; + min-height: 32px; + padding: 0 22px 0 8px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + font-size: 8px; +} +.publish-selection-bar { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--vd-line); + color: var(--vd-ink-muted); + font-size: 8px; +} +.publish-selection-bar label { display: inline-flex; align-items: center; gap: 6px; color: var(--vd-ink-secondary); cursor: pointer; } +.publish-selection-bar input, +.publish-card-check input { accent-color: var(--vd-brand); } +.publish-center-page .publish-list { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px; + overflow-y: auto; + scrollbar-width: thin; +} +.publish-list-card { + position: relative; + display: grid; + grid-template-columns: 16px 54px minmax(0, 1fr); + gap: 8px; + align-items: center; + min-height: 76px; + padding: 8px; + border: 1px solid transparent; + border-radius: 9px; + background: var(--vd-surface-raised); + cursor: pointer; + transition: border-color .16s ease, background .16s ease, transform .16s ease; +} +.publish-list-card:hover { border-color: var(--vd-line-strong); transform: translateY(-1px); } +.publish-list-card.is-active { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.publish-card-check { align-self: start; padding-top: 4px; cursor: pointer; } +.publish-card-media { + width: 54px; + height: 58px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-card-media img { width: 100%; height: 100%; object-fit: contain; object-position: center; padding: 3px; } +.publish-card-copy { min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-right: 4px; } +.publish-card-copy > strong { color: var(--vd-ink); font-size: 9px; line-height: 1.25; font-weight: 720; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.publish-card-sku { margin-top: 3px; color: var(--vd-ink-muted); font: 7.5px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-card-meta { margin-top: 7px; display: flex; align-items: center; justify-content: space-between; gap: 7px; color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-card-meta b { color: var(--vd-ink); font-size: 8.5px; font-weight: 720; } +.publish-card-status { + position: absolute; + right: 7px; + bottom: 7px; + min-height: 17px; + display: inline-flex; + align-items: center; + padding: 0 5px; + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 720; +} +.publish-card-status.status-published { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-card-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-card-status.status-pending, +.publish-card-status.status-uploading { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-card-status.status-copied { background: var(--vd-info-soft); color: var(--vd-info); } +.publish-card-status.status-prepared { background: var(--vd-brand-soft); color: var(--vd-brand); } +.publish-batch-bar { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 9px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-batch-bar .vd-button { flex: 1 1 auto; min-width: 68px; } + +.publish-workspace-panel { background: var(--vd-surface-raised); } +.publish-workspace { min-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; } +.publish-workspace-header { + min-height: 68px; + padding: 11px 14px; + border-bottom: 1px solid var(--vd-line); +} +.publish-workspace-product { min-width: 0; display: flex; align-items: center; gap: 10px; } +.publish-workspace-thumb { + width: 42px; + height: 42px; + flex: 0 0 auto; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; +} +.publish-workspace-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 2px; } +.publish-workspace-product > div:last-child { min-width: 0; } +.publish-workspace-product h3 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.publish-workspace-product p { margin-top: 3px; color: var(--vd-ink-muted); font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-workspace-header-actions { display: flex; align-items: center; gap: 5px; } +.publish-workspace-state { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 999px; background: var(--vd-success-soft); color: var(--vd-success); font-size: 8px; font-weight: 720; } +.icon-button { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-raised); + color: var(--vd-ink-secondary); + cursor: pointer; +} +.icon-button:hover { border-color: var(--vd-line-strong); color: var(--vd-ink); } +.icon-button.danger { color: var(--vd-danger); } +.icon-button.is-loading, +.vd-button.is-loading { opacity: .72; cursor: wait; pointer-events: none; } +@keyframes vd-publish-spin { to { transform: rotate(360deg); } } +.publish-workspace-loading, +.publish-workspace-empty, +.publish-workspace-error { + min-height: 440px; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 32px; + text-align: center; + color: var(--vd-ink-muted); +} +.publish-workspace-loading span { width: 22px; height: 22px; border: 2px solid var(--vd-line); border-top-color: var(--vd-brand); border-radius: 50%; animation: vd-publish-spin .8s linear infinite; } +.publish-workspace-empty > span, +.publish-workspace-error > span { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 14px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; } +.publish-workspace-empty h3, +.publish-workspace-error strong { color: var(--vd-ink); font-size: 13px; } +.publish-workspace-empty p, +.publish-workspace-error p { max-width: 340px; font-size: 9px; line-height: 1.55; } +.publish-workspace-empty.compact { min-height: 320px; } +.publish-smart-grid, +.publish-direct-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 226px; + overflow-y: auto; +} +.publish-editor-column { min-width: 0; padding: 13px; display: grid; align-content: start; gap: 10px; } +.publish-editor-section { padding-bottom: 10px; border-bottom: 1px solid var(--vd-line); } +.publish-editor-section:last-child { border-bottom: 0; } +.publish-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } +.publish-field-head label { color: var(--vd-ink-secondary); font-size: 8.5px; font-weight: 720; } +.publish-field-head span { color: var(--vd-ink-muted); font-size: 7.5px; } +.publish-field-head > div { display: flex; gap: 4px; } +.publish-field-head button, +.publish-copy-field button { + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + font-size: 7.5px; + font-weight: 680; + cursor: pointer; +} +.publish-copy-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; } +.publish-copy-field input, +.publish-editor-section > input, +.publish-editor-section textarea, +.publish-meta-form input { + width: 100%; + min-width: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: 9px/1.45 inherit; +} +.publish-copy-field input, +.publish-editor-section > input, +.publish-meta-form input { min-height: 34px; padding: 0 9px; } +.publish-editor-section textarea { min-height: 190px; padding: 9px; resize: vertical; } +.publish-copy-field input[readonly], +.publish-editor-section textarea[readonly] { color: var(--vd-ink-secondary); background: var(--vd-surface-muted); } +.publish-meta-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; } +.publish-meta-form.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.publish-meta-form label { min-width: 0; display: grid; gap: 4px; } +.publish-meta-form label > span { color: var(--vd-ink-muted); font-size: 7.5px; font-weight: 680; } +.publish-meta-form.compact .publish-copy-field { grid-template-columns: minmax(0, 1fr); } +.publish-meta-form.compact .publish-copy-field button { display: none; } +.publish-readiness-column { + min-width: 0; + padding: 13px; + display: grid; + align-content: start; + gap: 10px; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.publish-readiness-card, +.publish-photo-section { + padding: 10px; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-raised); +} +.publish-readiness-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.publish-readiness-head h4 { margin: 0; font-size: 12px; } +.publish-platform-wordmark { color: var(--vd-brand); font-size: 9px; font-weight: 800; } +.integration-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--vd-danger); box-shadow: 0 0 0 4px var(--vd-danger-soft); } +.integration-dot.is-connected { background: var(--vd-success); box-shadow: 0 0 0 4px var(--vd-success-soft); } +.publish-integration-message { margin: 8px 0 4px; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.publish-readiness-card ul { list-style: none; margin: 10px 0; display: grid; gap: 6px; } +.publish-readiness-card li { display: flex; align-items: center; gap: 7px; color: var(--vd-ink-secondary); font-size: 8.5px; } +.publish-readiness-card li > span { width: 15px; height: 15px; display: grid; place-items: center; border-radius: 50%; font-size: 8px; font-weight: 800; } +.publish-readiness-card li.is-ready > span { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-readiness-card li.is-missing > span { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message { padding: 9px; border-radius: 7px; background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-ready-message.is-ready { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-ready-message strong, +.publish-ready-message small { display: block; } +.publish-ready-message strong { font-size: 9px; } +.publish-ready-message small { margin-top: 2px; color: inherit; opacity: .8; font-size: 7.5px; line-height: 1.4; } +.publish-photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } +.publish-photo-grid img, +.publish-photo-grid > span { + width: 100%; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid var(--vd-line); + border-radius: 6px; + background: var(--vd-surface-muted); + color: var(--vd-ink-muted); + font-size: 7px; + object-fit: contain; + padding: 2px; +} +.publish-no-photos { min-height: 70px; display: grid; place-items: center; border: 1px dashed var(--vd-line-strong); border-radius: 7px; color: var(--vd-ink-muted); font-size: 8px; } +.vd-button.full { width: 100%; margin-top: 7px; justify-content: center; } +.publish-workspace-footer { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 13px; + border-top: 1px solid var(--vd-line); + background: var(--vd-surface-raised); +} +.publish-workspace-footer > div { display: flex; gap: 6px; } + +.publish-status-rail { + overflow-y: auto; + border-left: 1px solid var(--vd-line); + background: var(--vd-surface); + scrollbar-width: thin; +} +.publish-rail-section { padding: 14px; border-bottom: 1px solid var(--vd-line); } +.publish-rail-heading { margin-bottom: 10px; } +.publish-status-list { display: grid; } +.publish-status-row { + width: 100%; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--vd-line); + background: transparent; + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-status-row:last-child { border-bottom: 0; } +.publish-status-row:hover strong { color: var(--vd-brand); } +.publish-status-icon { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--vd-line); border-radius: 50%; font-size: 9px; font-weight: 800; } +.publish-status-icon.pending { color: var(--vd-ink-muted); } +.publish-status-icon.processing { color: var(--vd-info); border-color: var(--vd-info); } +.publish-status-icon.success { color: var(--vd-success); border-color: var(--vd-success); } +.publish-status-icon.danger { color: var(--vd-danger); border-color: var(--vd-danger); } +.publish-status-icon.scheduled { color: var(--vd-warning); border-color: var(--vd-warning); } +.publish-status-row strong, +.publish-status-row small { display: block; } +.publish-status-row strong { font-size: 8.5px; } +.publish-status-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-status-row b { color: var(--vd-ink); font-size: 10px; } +.publish-quick-actions { display: grid; gap: 7px; } +.publish-quick-action { + width: 100%; + min-height: 45px; + display: grid; + align-content: center; + padding: 8px 10px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); + color: var(--vd-ink); + text-align: left; + cursor: pointer; +} +.publish-quick-action:hover { border-color: var(--vd-line-strong); } +.publish-quick-action.primary { border-color: var(--vd-brand); background: var(--vd-brand); color: white; } +.publish-quick-action strong, +.publish-quick-action small { display: block; } +.publish-quick-action strong { font-size: 8.5px; } +.publish-quick-action small { margin-top: 2px; color: inherit; opacity: .72; font-size: 7px; } +.publish-activity-list { display: grid; gap: 8px; } +.publish-activity-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; } +.publish-activity-icon { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; } +.publish-activity-icon.type-published, +.publish-activity-icon.type-sold { background: var(--vd-success-soft); color: var(--vd-success); } +.publish-activity-icon.type-failed { background: var(--vd-danger-soft); color: var(--vd-danger); } +.publish-activity-icon.type-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning); } +.publish-activity-row strong, +.publish-activity-row small { display: block; } +.publish-activity-row strong { color: var(--vd-ink-secondary); font-size: 7.8px; line-height: 1.35; font-weight: 620; } +.publish-activity-row small { margin-top: 2px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-rail-empty { color: var(--vd-ink-muted); font-size: 8px; } +.publish-empty-state { min-height: 180px; display: grid; place-content: center; justify-items: center; gap: 4px; padding: 20px; text-align: center; } +.publish-empty-state strong { color: var(--vd-ink); font-size: 10px; } +.publish-empty-state span { color: var(--vd-ink-muted); font-size: 8px; } + +.publish-operation-header { + min-height: 74px; + padding: 13px 15px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-header p { margin-top: 3px; color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-header > div:last-child { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; } +.publish-operation-list, +.publish-schedule-list { min-height: 0; padding: 10px 13px; overflow-y: auto; } +.publish-operation-row { + min-height: 64px; + display: grid; + grid-template-columns: minmax(180px, 1.5fr) 100px 64px minmax(100px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-operation-row:hover { background: var(--vd-surface); } +.publish-operation-product { min-width: 0; display: flex; align-items: center; gap: 8px; } +.publish-operation-mark { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 8px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-product strong, +.publish-operation-product small { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-product strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-operation-product small { margin-top: 3px; color: var(--vd-ink-muted); font: 7px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-operation-meta span, +.publish-operation-meta b { display: block; } +.publish-operation-meta span { color: var(--vd-ink-muted); font-size: 7px; } +.publish-operation-meta b { margin-top: 3px; color: var(--vd-ink); font-size: 8px; } +.operation-status { display: inline-flex !important; width: fit-content; min-height: 19px; align-items: center; padding: 0 6px; border-radius: 5px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary) !important; } +.operation-status.status-published { background: var(--vd-success-soft); color: var(--vd-success) !important; } +.operation-status.status-failed { background: var(--vd-danger-soft); color: var(--vd-danger) !important; } +.operation-status.status-uploading, +.operation-status.status-pending, +.operation-status.status-scheduled { background: var(--vd-warning-soft); color: var(--vd-warning) !important; } +.publish-operation-error { min-width: 0; color: var(--vd-ink-muted); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-operation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } +.publish-operation-footer { min-height: 55px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 13px; border-top: 1px solid var(--vd-line); color: var(--vd-ink-muted); font-size: 8px; } +.publish-operation-footer strong { color: var(--vd-ink); } +.publish-schedule-row { + min-height: 62px; + display: grid; + grid-template-columns: 135px minmax(190px, 1fr) 90px minmax(80px, .7fr) 30px; + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid var(--vd-line); +} +.publish-schedule-date strong, +.publish-schedule-date span { display: block; } +.publish-schedule-date strong { color: var(--vd-ink); font-size: 8.5px; } +.publish-schedule-date span { margin-top: 3px; color: var(--vd-ink-muted); font-size: 7px; } +.publish-schedule-error { min-width: 0; color: var(--vd-danger); font-size: 7.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.publish-schedule-dialog { width: min(620px, 94vw); max-width: 620px; } +.publish-schedule-summary { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 13px; } +.publish-schedule-summary span, +.publish-schedule-summary b { padding: 4px 7px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-secondary); font-size: 7.5px; font-weight: 620; } +.publish-schedule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.publish-schedule-grid label { display: grid; gap: 5px; color: var(--vd-ink-secondary); font-size: 8px; font-weight: 680; } +.publish-schedule-grid select, +.publish-schedule-grid input { min-height: 40px; padding: 0 10px; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface-raised); color: var(--vd-ink); font-size: 9px; } +.publish-schedule-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 16px; } + +@media (max-width: 1320px) { + .publish-center-grid { grid-template-columns: 245px minmax(0, 1fr); } + .publish-status-rail { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr 1fr; border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-rail-section { border-bottom: 0; border-right: 1px solid var(--vd-line); } + .publish-rail-section:last-child { border-right: 0; } +} +@media (max-width: 960px) { + .publish-center-heading { align-items: flex-start; flex-direction: column; } + .publish-center-grid { grid-template-columns: 1fr; } + .publish-list-panel { max-height: 470px; border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-smart-grid, + .publish-direct-grid { grid-template-columns: 1fr; } + .publish-readiness-column { border-left: 0; border-top: 1px solid var(--vd-line); } + .publish-status-rail { grid-template-columns: 1fr; } + .publish-rail-section { border-right: 0; border-bottom: 1px solid var(--vd-line); } + .publish-operation-row { grid-template-columns: minmax(170px, 1fr) 90px 60px auto; } + .publish-operation-error { display: none; } +} +@media (max-width: 620px) { + .publish-heading-actions { width: 100%; } + .publish-heading-actions .vd-button { flex: 1; } + .publish-view-tab { padding: 0 10px; } + .publish-list-panel-head { align-items: flex-start; } + .publish-filter-row, + .publish-schedule-grid, + .publish-meta-form, + .publish-meta-form.compact { grid-template-columns: 1fr; } + .publish-workspace-header { align-items: flex-start; } + .publish-workspace-header-actions { flex-wrap: wrap; justify-content: flex-end; } + .publish-workspace-footer, + .publish-operation-footer { align-items: stretch; flex-direction: column; } + .publish-workspace-footer > div { display: grid; grid-template-columns: 1fr 1fr; } + .publish-workspace-footer .vd-button { justify-content: center; } + .publish-operation-header { align-items: flex-start; flex-direction: column; } + .publish-operation-header > div:last-child { width: 100%; justify-content: stretch; } + .publish-operation-header .vd-button { flex: 1; } + .publish-operation-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .publish-operation-meta, + .publish-operation-error { display: none; } + .publish-schedule-row { grid-template-columns: 1fr auto; } + .publish-schedule-row > :nth-child(3), + .publish-schedule-row > :nth-child(4) { display: none; } +} + +/* ========================================================================== + Slice 06 — Full Listing Editor + Live WYSIWYG/HTML synchronization + ========================================================================== */ +.generator-html-workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + padding: 0 10px 10px; +} +.generator-html-pane { + min-width: 0; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface-raised); +} +.generator-html-pane-head, +.detail-pane-head, +.detail-html-preview-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.generator-html-pane-head strong, +.detail-pane-head strong, +.detail-html-preview-head strong { color: var(--vd-ink); font-size: 8px; } +.generator-html-pane-head span, +.detail-pane-head span, +.detail-html-preview-head span { color: var(--vd-ink-muted); font-size: 7px; } +.generator-html-pane-head span[data-tone="working"], +.detail-html-preview-head span[data-tone="working"] { color: var(--vd-warning); } +.generator-html-pane-head span[data-tone="ok"], +.detail-html-preview-head span[data-tone="ok"] { color: var(--vd-success); } +.generator-html-pane-head span[data-tone="warning"], +.detail-html-preview-head span[data-tone="warning"] { color: var(--vd-warning); } +.generator-html-pane .html-source { + min-height: 240px; + margin: 0; + width: 100%; + border: 0; + border-radius: 0; + padding: 11px; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.html-preview-frame { + display: block; + width: 100%; + min-height: 240px; + margin: 0 !important; + border: 0; + background: #fff; +} + +.listings-page .history-detail { + padding: 0; + overflow: hidden; + border-color: var(--vd-line); + border-radius: 12px; + background: var(--vd-surface-raised); +} +.listings-page #back-to-list { margin: 12px 14px 0; } +.listing-editor-shell { padding: 0 14px 16px; } +.listing-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 17px 0 14px; + border-bottom: 1px solid var(--vd-line); +} +.listing-editor-kicker { + display: block; + margin-bottom: 4px; + color: var(--vd-brand); + font-size: 7px; + font-weight: 720; + letter-spacing: .08em; + text-transform: uppercase; +} +.listing-editor-header h2 { margin: 0; color: var(--vd-ink); font-size: 18px; line-height: 1.2; text-transform: none; letter-spacing: -.02em; } +.listing-editor-header p { margin: 5px 0 0; color: var(--vd-ink-muted); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: normal; } +.listing-editor-header-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } +.listing-editor-grid { + display: grid; + grid-template-columns: minmax(220px, .78fr) minmax(420px, 1.65fr) minmax(190px, .62fr); + gap: 12px; + align-items: start; + padding-top: 13px; +} +.listing-media-editor, +.listing-content-editor, +.listing-context-panel { min-width: 0; } +.listing-media-editor, +.listing-editor-section, +.listing-context-card { + border: 1px solid var(--vd-line); + border-radius: 10px; + background: var(--vd-surface-raised); +} +.listing-media-editor { padding: 10px; position: sticky; top: 74px; } +.listing-content-editor { display: grid; gap: 10px; } +.listing-editor-section { padding: 11px; } +.listing-context-panel { display: grid; gap: 9px; position: sticky; top: 74px; } +.listing-context-card { padding: 11px; } +.listing-context-card h3, +.listing-section-heading h3 { margin: 0; color: var(--vd-ink); font-size: 9px; text-transform: none; letter-spacing: 0; } +.listing-section-heading { + min-height: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.listing-section-heading > div { display: flex; align-items: center; gap: 7px; } +.listing-section-heading > div > span { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--vd-brand-soft); + color: var(--vd-brand); + font-size: 7px; + font-weight: 760; +} +.listing-section-heading > b { color: var(--vd-ink-muted); font-size: 7px; font-weight: 600; } +.detail-photo-stage { + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 9px; + background: var(--vd-surface-muted); +} +.detail-photo-stage img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-empty { color: var(--vd-ink-muted); font-size: 8px; } +.detail-photo-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + margin-top: 7px; +} +.detail-photo-thumb { + position: relative; + aspect-ratio: 1; + overflow: hidden; + padding: 0; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface-muted); + cursor: pointer; +} +.detail-photo-thumb:hover, +.detail-photo-thumb.is-active { border-color: var(--vd-brand); box-shadow: 0 0 0 2px var(--vd-brand-soft); } +.detail-photo-thumb.is-dragging { opacity: .45; } +.detail-photo-thumb img { width: 100%; height: 100%; object-fit: contain; object-position: center; } +.detail-photo-thumb span { + position: absolute; + left: 3px; + bottom: 3px; + padding: 2px 4px; + border-radius: 4px; + background: rgba(21, 20, 17, .82); + color: #fff; + font-size: 5.5px; + font-weight: 720; +} +.detail-photo-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 8px; } +.detail-photo-actions .vd-button { justify-content: center; min-width: 0; padding-inline: 6px; } +.detail-photo-help { margin: 8px 0 0 !important; color: var(--vd-ink-muted); font-size: 6.5px !important; line-height: 1.5 !important; white-space: normal !important; } +.listing-form-grid { display: grid; gap: 8px; } +.listing-form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.listing-form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.listing-form-grid label, +.listing-full-field { + min-width: 0; + display: grid; + gap: 5px; + color: var(--vd-ink-secondary); + font-size: 7px; + font-weight: 680; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field { position: relative; margin-top: 9px; } +.listing-full-field > span { position: absolute; right: 1px; top: 0; color: var(--vd-ink-muted); font-size: 6.5px; font-weight: 500; } +.listing-form-grid input, +.listing-form-grid select, +.listing-full-field input, +.listing-full-field textarea { + width: 100%; + min-height: 38px; + padding: 0 9px; + border: 1px solid var(--vd-line); + border-radius: 7px; + background: var(--vd-surface); + color: var(--vd-ink); + font: inherit; + font-size: 8px; + text-transform: none; + letter-spacing: 0; +} +.listing-full-field textarea { min-height: 76px; padding-block: 9px; resize: vertical; } +.listing-form-grid input:focus, +.listing-form-grid select:focus, +.listing-full-field input:focus, +.listing-full-field textarea:focus, +.detail-description-pane textarea:focus { outline: none; border-color: var(--vd-brand); box-shadow: 0 0 0 3px var(--vd-brand-soft); } +.detail-description-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } +.detail-description-pane { min-width: 0; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; background: var(--vd-surface); } +.detail-description-pane #detail-description-editor { min-height: 245px; } +.detail-description-pane .ql-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-description-pane .ql-container { border: 0; min-height: 201px; font-size: 9px; } +.detail-description-pane textarea { + display: block; + width: 100%; + min-height: 245px; + padding: 10px; + border: 0; + resize: vertical; + background: var(--vd-code-bg, #171714); + color: #f3f0e7; + font: 7.5px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + tab-size: 2; +} +.detail-html-preview-head { margin-top: 8px; border: 1px solid var(--vd-line); border-bottom: 0; border-radius: 8px 8px 0 0; } +.detail-html-preview { display: block; width: 100%; min-height: 280px; border: 1px solid var(--vd-line); border-radius: 0 0 8px 8px; background: #fff; } +.listing-context-card canvas { display: block; width: 118px; height: 118px; margin: 2px auto 8px; padding: 7px; border: 1px solid var(--vd-line); border-radius: 8px; background: #fff; } +.listing-context-card > strong { display: block; overflow-wrap: anywhere; color: var(--vd-ink); text-align: center; font: 8px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.listing-context-card .vd-button.full { width: 100%; justify-content: center; margin-top: 6px; } +.listing-context-card.danger-zone { border-color: color-mix(in srgb, var(--vd-danger) 35%, var(--vd-line)); } + +@media (max-width: 1280px) { + .listing-editor-grid { grid-template-columns: 230px minmax(0, 1fr); } + .listing-context-panel { grid-column: 1 / -1; position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .listing-context-card { min-height: 120px; } +} +@media (max-width: 920px) { + .generator-html-workbench, + .detail-description-split { grid-template-columns: 1fr; } + .listing-editor-grid { grid-template-columns: 1fr; } + .listing-media-editor, + .listing-context-panel { position: static; } + .listing-context-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-photo-stage { max-height: 470px; aspect-ratio: 4 / 3; } +} +@media (max-width: 620px) { + .listing-editor-header { flex-direction: column; } + .listing-editor-header-actions { width: 100%; } + .listing-editor-header-actions .vd-button { flex: 1; justify-content: center; } + .listing-form-grid.three, + .listing-form-grid.two, + .listing-context-panel { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .generator-html-workbench { padding-inline: 7px; } +} + +.history-detail .detail-photo-stage img, .history-detail .detail-photo-thumb img { object-fit: contain; object-position: center; } + +/* ========================================================================== + Slice 07: Cross-browser extensions, responsive shell & mobile photo upload + ========================================================================== */ +html, body, .main-wrapper, main, .tab-content { min-width: 0; } +img, video, canvas, iframe { max-width: 100%; } + +.mobile-upload-trigger { + width: calc(100% - 24px); + min-height: 52px; + margin: 8px 12px 0; + padding: 8px 10px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + border: 1px solid var(--vd-line); + border-radius: 8px; + background: var(--vd-surface); + color: var(--vd-ink-secondary); + text-align: left; + cursor: pointer; +} +.mobile-upload-trigger:hover { border-color: var(--vd-brand); background: var(--vd-brand-soft); } +.mobile-upload-trigger-icon { + width: 32px; height: 32px; display: grid; place-items: center; + border-radius: 8px; background: var(--vd-brand-soft); color: var(--vd-brand); font-size: 18px; font-weight: 800; +} +.mobile-upload-trigger span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.mobile-upload-trigger strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-trigger small { color: var(--vd-ink-muted); font-size: 7px; line-height: 1.35; } +.mobile-upload-trigger b { padding: 4px 6px; border-radius: 5px; background: var(--vd-ink); color: var(--vd-surface); font-size: 7px; } + +.html-view-toolbar { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 10px; + border-top: 1px solid var(--vd-line); + border-bottom: 1px solid var(--vd-line); + background: var(--vd-surface); +} +.html-view-tabs { display: inline-flex; gap: 3px; padding: 3px; border-radius: 7px; background: var(--vd-surface-muted); } +.html-view-tab { + min-height: 28px; padding: 0 9px; border: 0; border-radius: 5px; + background: transparent; color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; cursor: pointer; +} +.html-view-tab.is-active { background: var(--vd-surface-raised); color: var(--vd-ink); box-shadow: 0 1px 5px rgba(20,18,15,.08); } +.html-switch-stage { min-width: 0; } +.html-switch-view { display: none; min-width: 0; } +.html-switch-view.is-active { display: block; } +.html-switch-view .html-source, +.detail-html-switch-stage textarea { + display: block; width: 100%; min-height: 290px; margin: 0; padding: 12px; + border: 0; border-radius: 0; resize: vertical; background: var(--vd-code-bg, #171714); color: #f3f0e7; + font: 8px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; +} +.html-switch-view .html-preview-frame { min-height: 320px; } +.detail-platform-workbench { margin-top: 9px; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.detail-html-toolbar { border: 0; border-bottom: 1px solid var(--vd-line); } +.detail-html-toolbar > span { color: var(--vd-ink-muted); font-size: 7px; } +.detail-html-switch-stage .detail-html-preview { min-height: 340px; border: 0; border-radius: 0; } +.detail-wysiwyg-pane { width: 100%; } +.detail-wysiwyg-pane #detail-description-editor { min-height: 270px; } +.detail-photo-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.mobile-upload-overlay { z-index: 2500; padding: 18px; } +.mobile-upload-dialog { + width: min(760px, 100%); max-height: min(760px, calc(100dvh - 36px)); overflow: auto; + border: 1px solid var(--vd-line); border-radius: 16px; background: var(--vd-surface-raised); box-shadow: var(--vd-shadow-float); +} +.mobile-upload-dialog > header, +.mobile-upload-dialog > footer { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; } +.mobile-upload-dialog > header { border-bottom: 1px solid var(--vd-line); } +.mobile-upload-dialog > footer { justify-content: flex-end; border-top: 1px solid var(--vd-line); } +.mobile-upload-dialog-brand { display: flex; align-items: center; gap: 10px; } +.mobile-upload-dialog-brand > div { display: grid; gap: 2px; } +.mobile-upload-dialog-brand span { color: var(--vd-brand); font-size: 7px; font-weight: 760; text-transform: uppercase; letter-spacing: .08em; } +.mobile-upload-dialog-brand h3 { margin: 0; color: var(--vd-ink); font-size: 15px; } +.mobile-upload-dialog-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: var(--vd-brand); color: #fff !important; font-size: 9px !important; } +.mobile-upload-dialog-grid { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 22px; padding: 22px; } +.mobile-upload-qr-card { display: grid; justify-items: center; align-content: center; padding: 18px; border: 1px solid var(--vd-line); border-radius: 13px; background: #fff; } +.mobile-upload-qr-card canvas { width: 240px; height: 240px; padding: 8px; background: #fff; } +.mobile-upload-session-state { min-height: 28px; display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 0 9px; border-radius: 999px; background: var(--vd-surface-muted); color: var(--vd-ink-muted); font-size: 8px; font-weight: 720; } +.mobile-upload-session-state i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.mobile-upload-session-state[data-tone="ready"] { background: var(--vd-success-soft); color: var(--vd-success); } +.mobile-upload-session-state[data-tone="warning"] { background: var(--vd-warning-soft); color: var(--vd-warning); } +.mobile-upload-session-state[data-tone="error"] { background: var(--vd-danger-soft); color: var(--vd-danger); } +.mobile-upload-dialog-copy { min-width: 0; align-self: center; } +.mobile-upload-dialog-copy h4 { margin: 4px 0 12px; color: var(--vd-ink); font-size: 21px; line-height: 1.15; letter-spacing: -.04em; } +.mobile-upload-dialog-copy ol { display: grid; gap: 9px; margin: 0 0 16px; padding-left: 22px; color: var(--vd-ink-secondary); font-size: 10px; line-height: 1.45; } +.mobile-upload-link-wrap { display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--vd-line); border-radius: 8px; } +.mobile-upload-link-wrap input { min-width: 0; height: 38px; border: 0; border-radius: 0; padding: 0 9px; background: var(--vd-surface); color: var(--vd-ink-secondary); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.mobile-upload-link-wrap button { border: 0; border-left: 1px solid var(--vd-line); padding: 0 10px; background: var(--vd-surface-muted); color: var(--vd-brand); font-size: 8px; font-weight: 720; cursor: pointer; } +.mobile-upload-dialog-copy > p { margin: 9px 0 0; color: var(--vd-ink-muted); font-size: 8px; line-height: 1.5; } +.mobile-upload-live { display: flex; justify-content: space-between; gap: 10px; margin-top: 15px; padding: 10px; border-radius: 8px; background: var(--vd-surface-muted); } +.mobile-upload-live strong { color: var(--vd-ink); font-size: 9px; } +.mobile-upload-live span { color: var(--vd-ink-muted); font-size: 8px; } + +/* Fluid page widths and device adaptations */ +.main-wrapper { width: calc(100% - var(--vd-sidebar-width)); max-width: 100%; } +main { width: 100%; overflow-x: clip; } +.history-table-wrap, .publish-workspace, .storage-main, .admin-grid, .settings-form { min-width: 0; } +.history-table-wrap { overflow-x: auto; } +.history-table { min-width: 980px; } +.generator-panel, .work-panel, .listing-editor-section, .listing-media-editor, .listing-context-card { max-width: 100%; } + +@media (max-width: 1440px) { + .generator-studio-layout { grid-template-columns: minmax(240px,.72fr) minmax(300px,.95fr) minmax(330px,1.1fr); } + main { padding-inline: 18px; } +} +@media (max-width: 1180px) { + .topbar-context { min-width: 150px; } + .command-search { width: min(390px, 34vw); } + .listing-editor-grid { grid-template-columns: 230px minmax(0,1fr); } + .listing-context-panel { grid-template-columns: repeat(2,minmax(0,1fr)); } +} +@media (max-width: 1024px) { + :root { --vd-sidebar-width: 74px; } + .sidebar-header { justify-content: center; padding-inline: 10px; } + .logo-text, .nav-item span, .nav-group-label, .sidebar-footer .theme-toggle span { display: none; } + .nav-item { justify-content: center; padding: 0; } + .nav-item::before { left: -10px; } + .sidebar-footer .theme-toggle { justify-content: center; padding: 0; } + .main-wrapper { width: calc(100% - var(--vd-sidebar-width)); } + .topbar { padding-inline: 14px; } + .generator-studio-layout { grid-template-columns: 1fr 1fr; } + .generator-preview-panel { grid-column: 1 / -1; } + .studio-layout { grid-template-columns: 1fr; } + .studio-inspector { position: static; } + .publish-center-layout { grid-template-columns: minmax(0,1fr); } + .mobile-upload-dialog-grid { grid-template-columns: 250px minmax(0,1fr); gap: 16px; } +} +@media (max-width: 768px) { + :root { --vd-sidebar-width: 0px; } + body { display: block; } + .main-wrapper { width: 100%; margin-left: 0; } + .topbar { position: sticky; top: 0; z-index: 100; } + .topbar-context { flex: 0 1 auto; } + .topbar-actions { gap: 3px; } + main { padding: 13px 10px 22px; } + .page-heading { align-items: flex-start; flex-direction: column; gap: 10px; } + .generator-studio-layout, .listing-editor-grid { grid-template-columns: 1fr; } + .generator-preview-panel, .listing-media-editor, .listing-context-panel { position: static; max-height: none; } + .listing-context-panel { grid-template-columns: 1fr 1fr; } + .studio-bottom-grid { grid-template-columns: 1fr; } + .flow-board { min-width: 860px; } + .mobile-upload-dialog-grid { grid-template-columns: 1fr; padding: 15px; } + .mobile-upload-qr-card canvas { width: 210px; height: 210px; } + .mobile-upload-dialog-copy { text-align: left; } + .html-view-toolbar { align-items: flex-start; flex-direction: column; } + .generator-html-actions { width: 100%; justify-content: flex-start; overflow-x: auto; padding: 0; } +} +@media (max-width: 560px) { + .topbar { min-height: 54px; height: 54px; } + .topbar-title { max-width: 105px; } + .command-search { max-width: 42px; padding: 0; justify-content: center; } + .command-search svg { margin: 0; } + .generator-stepper { overflow-x: auto; justify-content: flex-start; } + .generator-step { min-width: max-content; flex: 0 0 auto; } + .listing-context-panel, .listing-form-grid.two, .listing-form-grid.three { grid-template-columns: 1fr; } + .detail-photo-actions { grid-template-columns: 1fr; } + .mobile-upload-overlay { padding: 7px; align-items: flex-end; } + .mobile-upload-dialog { max-height: calc(100dvh - 14px); border-radius: 16px 16px 8px 8px; } + .mobile-upload-dialog > header, .mobile-upload-dialog > footer { padding-inline: 12px; } + .mobile-upload-dialog > footer { display: grid; grid-template-columns: 1fr 1fr; } + .mobile-upload-dialog > footer button { min-width: 0; } + .mobile-upload-live { align-items: flex-start; flex-direction: column; } + .html-view-tabs { width: 100%; } + .html-view-tab { flex: 1; } + .history-table { min-width: 850px; } +} + +/* ========================================================================== + Slice 10 — Dashboard, Studio Flow V2 & responsive hardening + ========================================================================== */ +.dashboard-page,.studio-flow-v2{min-width:0} +.dashboard-heading{align-items:center}.dashboard-controls{display:flex;align-items:center;gap:8px}.dashboard-controls select{height:34px;padding:0 34px 0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:11px} +.dashboard-layout{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:14px;align-items:start}.dashboard-main{min-width:0}.dashboard-rail{display:grid;gap:12px;min-width:0}.dashboard-card{min-width:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);box-shadow:none;overflow:hidden}.dashboard-card>header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 13px 9px}.dashboard-card>header>div{display:grid;gap:3px}.dashboard-card>header span{font-size:12px;font-weight:750;color:var(--vd-ink)}.dashboard-card>header strong{font-size:10px;font-weight:500;color:var(--vd-ink-muted)} +.dashboard-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:12px}.dashboard-kpi{position:relative;min-width:0;min-height:116px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);overflow:hidden}.dashboard-kpi-icon{width:24px;height:24px;display:grid;place-items:center;border-radius:var(--vd-radius-xs);background:var(--vd-success-soft);color:var(--vd-success);font-size:11px;font-weight:800}.dashboard-kpi>div{display:grid;gap:3px;margin-top:7px}.dashboard-kpi small{font-size:9px;color:var(--vd-ink-secondary)}.dashboard-kpi strong{font-size:17px;letter-spacing:-.035em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-kpi em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.dashboard-kpi svg{position:absolute;left:12px;right:12px;bottom:8px;width:calc(100% - 24px);height:25px;color:var(--vd-success)}.dashboard-kpi svg[data-tone="blue"]{color:var(--vd-info)}.dashboard-kpi svg[data-tone="purple"]{color:#8054b3}.dashboard-kpi svg[data-tone="orange"]{color:var(--vd-brand)} +.dashboard-chart-grid{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:12px;margin-bottom:12px}.dashboard-chart{height:250px;padding:0 8px 8px}.dashboard-chart svg{width:100%;height:100%;overflow:visible}.chart-grid line{stroke:var(--vd-line);stroke-width:1}.chart-grid text{fill:var(--vd-ink-muted);font-size:10px}.chart-area{fill:var(--vd-brand-soft)}.chart-line{fill:none;stroke:var(--vd-brand);stroke-width:2.5;vector-effect:non-scaling-stroke}.chart-point{fill:var(--vd-surface-raised);stroke:var(--vd-brand);stroke-width:2;cursor:pointer}.dashboard-bar-chart{height:250px;display:flex;align-items:end;justify-content:space-around;gap:12px;padding:16px 14px 12px}.dashboard-bar-item{height:100%;min-width:0;flex:1;display:grid;grid-template-rows:18px 1fr 18px;gap:5px;text-align:center}.dashboard-bar-item>strong{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap}.dashboard-bar-item>div{display:flex;align-items:end;justify-content:center;border-bottom:1px solid var(--vd-line)}.dashboard-bar-item i{display:block;width:min(44px,72%);min-height:5px;background:var(--vd-info);border-radius:var(--vd-radius-xs) var(--vd-radius-xs) 0 0}.dashboard-bar-item:nth-child(2) i{background:var(--vd-success)}.dashboard-bar-item:nth-child(3) i{background:var(--vd-ink)}.dashboard-bar-item:nth-child(4) i{background:var(--vd-brand)}.dashboard-bar-item span{font-size:9px;color:var(--vd-ink-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-category-chart{min-height:250px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:14px;align-items:center;padding:12px 14px}.dashboard-donut{width:138px;aspect-ratio:1;border-radius:50%;display:grid;place-items:center}.dashboard-donut>div{width:72%;aspect-ratio:1;border-radius:50%;display:grid;place-items:center;align-content:center;background:var(--vd-surface-raised);text-align:center}.dashboard-donut strong{font-size:13px}.dashboard-donut span{font-size:9px;color:var(--vd-ink-muted)}.dashboard-category-legend{display:grid;gap:8px}.dashboard-category-legend>div{display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:7px;align-items:center;font-size:9px}.dashboard-category-legend i{width:7px;height:7px;border-radius:50%}.dashboard-category-legend span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-category-legend strong{font-size:9px} +.dashboard-focus-card{margin-bottom:12px}.dashboard-focus-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;padding:0 12px 12px}.dashboard-focus-item{min-height:108px;padding:11px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface);color:var(--vd-ink);display:grid;grid-template-rows:auto 1fr auto;gap:6px;text-align:left;cursor:pointer}.dashboard-focus-item:hover{border-color:var(--vd-brand);transform:translateY(-1px)}.dashboard-focus-item strong{font-size:18px}.dashboard-focus-item span{font-size:10px;line-height:1.35}.dashboard-focus-item em{font-size:9px;font-style:normal;color:var(--vd-brand)}.dashboard-focus-item.tone-ready{background:color-mix(in srgb,var(--vd-success-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-failed{background:color-mix(in srgb,var(--vd-danger-soft) 72%,var(--vd-surface))}.dashboard-focus-item.tone-uploads{background:color-mix(in srgb,var(--vd-info-soft) 72%,var(--vd-surface))} +.dashboard-lower-grid{display:grid;grid-template-columns:minmax(0,1fr) 255px;gap:12px}.dashboard-table-wrap{overflow:auto}.dashboard-table{width:100%;border-collapse:collapse;font-size:9px}.dashboard-table th,.dashboard-table td{padding:8px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.dashboard-table th{color:var(--vd-ink-muted);font-weight:600}.dashboard-table tbody tr{cursor:pointer}.dashboard-table tbody tr:hover{background:var(--vd-surface-muted)}.dashboard-product{display:flex;align-items:center;gap:8px;min-width:210px}.dashboard-product img,.dashboard-product>span{width:34px;height:34px;object-fit:contain;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted)}.dashboard-product>div{display:grid;gap:2px}.dashboard-product strong{font-size:9px}.dashboard-product small{font-size:8px;color:var(--vd-ink-muted)}.dashboard-status{padding:3px 6px;background:var(--vd-success-soft);color:var(--vd-success);font-size:8px}.dashboard-actions{display:grid;gap:7px;padding:0 12px 12px}.dashboard-actions button{min-height:34px;padding:0 10px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-xs);background:var(--vd-surface-raised);color:var(--vd-ink);font:inherit;font-size:10px;font-weight:700;text-align:left;cursor:pointer}.dashboard-actions button:hover{border-color:var(--vd-brand);color:var(--vd-brand)}.dashboard-actions button.primary{border-color:var(--vd-brand);background:var(--vd-brand);color:#fff;text-align:center}.dashboard-quality{display:grid;grid-template-columns:58px minmax(0,1fr);gap:11px;align-items:center;padding:0 13px 12px}.dashboard-quality .confidence-ring{width:56px;height:56px}.dashboard-quality>div:last-child{display:grid;gap:3px}.dashboard-quality strong{font-size:11px;color:var(--vd-success)}.dashboard-quality p{font-size:9px;line-height:1.4;color:var(--vd-ink-muted)}.dashboard-card>.vd-button.full{width:calc(100% - 26px);margin:0 13px 13px}.dashboard-feed{display:grid;padding:0 12px 12px}.dashboard-feed button{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px;align-items:start;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.dashboard-feed button:first-child{border-top:0}.dashboard-feed-icon{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:9px;font-weight:800}.dashboard-feed-icon.tone-danger,.dashboard-feed-icon.tone-failed{background:var(--vd-danger-soft);color:var(--vd-danger)}.dashboard-feed button>span:last-child{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 6px}.dashboard-feed strong{font-size:9px;line-height:1.35}.dashboard-feed small{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.dashboard-system p{padding:0 13px 12px;font-size:9px;line-height:1.45;color:var(--vd-ink-muted)}.dashboard-empty{padding:18px!important;text-align:center;color:var(--vd-ink-muted)!important;font-size:10px!important} + +/* Studio Flow V2 */ +.today-summary-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:12px}.today-summary-grid>button{min-height:78px;padding:12px;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);background:var(--vd-surface-raised);color:var(--vd-ink);display:grid;grid-template-columns:28px 42px minmax(0,1fr);grid-template-rows:auto auto;align-items:center;gap:2px 9px;text-align:left;cursor:pointer}.today-summary-grid>button:hover{border-color:var(--vd-brand)}.today-summary-grid>button>span{grid-row:1/3;width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:var(--vd-success-soft);color:var(--vd-success);font-size:15px}.today-summary-grid>button:last-child>span{background:var(--vd-danger-soft);color:var(--vd-danger)}.today-summary-grid strong{grid-row:1/3;font-size:20px}.today-summary-grid small{font-size:10px;font-weight:700}.today-summary-grid em{font-size:8px;font-style:normal;color:var(--vd-ink-muted)}.today-filterbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.today-filter-tabs{display:flex;align-items:center;gap:5px}.today-filter-tabs button,.today-filterbar select{height:32px;padding:0 12px;border:1px solid transparent;border-radius:var(--vd-radius-xs);background:var(--vd-surface-muted);color:var(--vd-ink-secondary);font:inherit;font-size:10px;cursor:pointer}.today-filter-tabs button.active{border-color:var(--vd-brand);background:var(--vd-surface-raised);color:var(--vd-brand)}.today-filterbar select{border-color:var(--vd-line);background:var(--vd-surface-raised)}.studio-layout-v2{grid-template-columns:minmax(0,1fr) 300px}.today-ready-panel{margin-bottom:12px}.today-ready-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:0 12px 12px}.today-product-card{min-width:0;padding:0;border:1px solid var(--vd-line);border-radius:var(--vd-radius-sm);overflow:hidden;background:var(--vd-surface-raised);color:var(--vd-ink);text-align:left;cursor:pointer}.today-product-card:hover,.today-product-card.selected{border-color:var(--vd-brand);box-shadow:0 0 0 1px var(--vd-brand)}.today-product-media{height:135px;position:relative;display:grid;place-items:center;background:linear-gradient(145deg,var(--vd-surface),var(--vd-surface-muted));overflow:hidden}.today-product-media img{width:100%;height:100%;object-fit:contain;padding:8px}.today-product-media>b{position:absolute;right:6px;top:6px;padding:3px 5px;background:color-mix(in srgb,var(--vd-surface-raised) 92%,transparent);font-size:8px}.today-product-copy{display:grid;gap:4px;padding:9px}.today-product-copy>strong{font-size:10px;line-height:1.35;min-height:27px}.today-product-copy>span{font-size:8px;color:var(--vd-ink-muted)}.today-product-copy>div{display:flex;align-items:center;justify-content:space-between;gap:8px}.today-product-copy em{font-size:11px;font-style:normal;font-weight:750}.today-product-copy small{font-size:8px;color:var(--vd-success)}.today-middle-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(235px,.65fr);gap:12px;margin-bottom:12px}.today-table-wrap{overflow:auto}.today-table{width:100%;border-collapse:collapse;font-size:9px}.today-table th,.today-table td{padding:7px 10px;border-top:1px solid var(--vd-line);text-align:left;white-space:nowrap}.today-table th{color:var(--vd-ink-muted);font-weight:600}.today-table td:first-child button{display:flex;align-items:center;gap:8px;border:0;background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-table td:first-child button>span{width:34px;height:28px;display:grid;place-items:center}.today-table td:first-child img{width:34px;height:28px;object-fit:contain}.today-table td:first-child div{display:grid;gap:2px}.today-table td:first-child strong{font-size:9px}.today-table td:first-child small{font-size:8px;color:var(--vd-ink-muted)}.today-status{padding:3px 6px;background:var(--vd-warning-soft);color:var(--vd-warning);font-size:8px}.attention-list.compact{padding:0 10px}.attention-list.compact .attention-item{width:100%;display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:8px;align-items:center;padding:8px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.attention-list.compact .attention-item:first-child{border-top:0}.attention-symbol{width:18px;height:18px;display:grid;place-items:center;color:var(--vd-brand);font-size:10px}.attention-copy{display:grid;gap:2px}.attention-copy strong{font-size:9px}.attention-copy small{font-size:8px;color:var(--vd-ink-muted)}.attention-list.compact .attention-item>b{font-size:9px}.today-panel-footer{display:block;margin:8px auto 10px}.today-activity-panel{margin-bottom:0}.today-activity-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:0 12px 12px}.today-activity-grid>div{min-width:0}.today-activity-grid h4{margin-bottom:6px;font-size:9px}.today-activity-grid .activity-item{width:100%;display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 0;border:0;border-top:1px solid var(--vd-line);background:transparent;color:var(--vd-ink);text-align:left;cursor:pointer}.today-activity-grid .activity-item:first-child{border-top:0}.today-activity-grid .activity-copy{font-size:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.today-activity-grid .activity-time{font-size:8px;color:var(--vd-ink-muted);white-space:nowrap}.inspector-photo-count{position:absolute;left:8px;bottom:8px;padding:4px 6px;background:rgba(0,0,0,.66);color:#fff;font-size:8px}.inspector-title-row{display:flex;align-items:start;justify-content:space-between;gap:8px}.inspector-title-row>div{min-width:0}.inspector-thumbs>span{display:grid;place-items:center;border:1px solid var(--vd-line);font-size:9px;color:var(--vd-ink-muted)} +.publish-loading-state{min-height:250px;display:grid;place-items:center;align-content:center;gap:10px;color:var(--vd-ink-muted)}.publish-loading-state>span{width:28px;height:28px;border:2px solid var(--vd-line);border-top-color:var(--vd-brand);border-radius:50%;animation:spin .8s linear infinite}.publish-loading-state strong{font-size:11px}@keyframes spin{to{transform:rotate(360deg)}} + +/* Width and device hardening */ +@media(max-width:1450px){.dashboard-kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1.35fr 1fr}.dashboard-chart-grid>.dashboard-card:last-child{grid-column:1/-1}.dashboard-category-chart{grid-template-columns:180px minmax(0,1fr)}.today-ready-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n+4){display:none}} +@media(max-width:1180px){.dashboard-layout{grid-template-columns:1fr}.dashboard-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr 1fr}.dashboard-line-card{grid-column:1/-1}.dashboard-lower-grid{grid-template-columns:1fr}.studio-layout-v2{grid-template-columns:1fr}.studio-layout-v2 .studio-inspector{position:relative;top:auto;max-height:none;display:none}.studio-layout-v2 .studio-inspector:has(.inspector-content:not(.hidden)){display:block}.today-middle-grid{grid-template-columns:1fr}.today-ready-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.today-ready-grid>.today-product-card:nth-child(n){display:block}} +@media(max-width:900px){.dashboard-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-chart-grid{grid-template-columns:1fr}.dashboard-chart-grid>.dashboard-card{grid-column:auto!important}.dashboard-focus-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dashboard-rail{grid-template-columns:1fr}.today-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-summary-grid>button:last-child{grid-column:1/-1}.today-ready-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.today-filterbar{align-items:stretch;flex-direction:column}.today-filter-tabs{overflow:auto;padding-bottom:2px}.today-filter-tabs button{flex:0 0 auto}.today-activity-grid{grid-template-columns:1fr}.dashboard-heading{align-items:flex-start}.dashboard-controls{width:100%}.dashboard-controls select{flex:1}} +@media(max-width:620px){.dashboard-kpis{grid-template-columns:1fr}.dashboard-kpi{min-height:105px}.dashboard-focus-grid{grid-template-columns:1fr}.dashboard-category-chart{grid-template-columns:1fr;justify-items:center}.dashboard-category-legend{width:100%}.dashboard-line-card .dashboard-chart{height:220px;overflow-x:auto}.dashboard-line-card svg{min-width:620px}.dashboard-lower-grid{display:block}.dashboard-actions-card{margin-top:12px}.today-summary-grid{grid-template-columns:1fr}.today-summary-grid>button:last-child{grid-column:auto}.today-ready-grid{grid-template-columns:1fr}.today-product-card{display:grid;grid-template-columns:115px minmax(0,1fr)}.today-product-media{height:108px}.today-table{min-width:650px}.today-activity-grid{padding-inline:10px}.dashboard-controls{flex-direction:column;align-items:stretch}.dashboard-controls .vd-button{width:100%}} + +/* ========================================================================== + Slice 11 — dashboard-first, fluid analytics & Studio Flow image hardening + ========================================================================== */ +.dashboard-page, +.dashboard-layout, +.dashboard-main, +.dashboard-chart-grid, +.dashboard-kpis, +.today-ready-grid, +.today-product-card { min-width: 0; } + +/* KPI cards determine their own column count from the actually available width. */ +.dashboard-kpis { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); + align-items: stretch; +} +.dashboard-kpi { + min-height: 132px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + grid-template-rows: minmax(62px, auto) 32px; + column-gap: 10px; + row-gap: 8px; + align-content: start; + padding: 12px; +} +.dashboard-kpi-icon { + grid-column: 1; + grid-row: 1; + align-self: start; + flex: none; +} +.dashboard-kpi > div { + grid-column: 2; + grid-row: 1; + min-width: 0; + margin: 0; + align-content: start; +} +.dashboard-kpi small, +.dashboard-kpi em { + overflow-wrap: anywhere; +} +.dashboard-kpi strong { + font-size: clamp(16px, 1.2vw, 20px); + line-height: 1.15; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dashboard-kpi svg { + position: static; + grid-column: 1 / -1; + grid-row: 2; + align-self: end; + width: 100%; + height: 30px; + max-width: none; + overflow: visible; +} + +.dashboard-chart-grid { + grid-template-columns: repeat(12, minmax(0, 1fr)); +} +.dashboard-line-card { grid-column: span 5; } +.dashboard-chart-grid > .dashboard-card:nth-child(2) { grid-column: span 4; } +.dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: span 3; } +.dashboard-chart, +.dashboard-bar-chart, +.dashboard-category-chart { min-width: 0; width: 100%; } +.dashboard-chart { overflow: hidden; } +.dashboard-chart svg { display: block; width: 100%; max-width: 100%; } +.dashboard-bar-chart { gap: clamp(5px, 1vw, 12px); } +.dashboard-bar-item { min-width: 42px; } +.dashboard-category-chart { grid-template-columns: minmax(112px, 42%) minmax(0, 1fr); } +.dashboard-donut { width: min(100%, 150px); } + +/* Ready products always retain the complete product photo. */ +.today-ready-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); + align-items: stretch; +} +.today-ready-grid > .today-product-card:nth-child(n) { display: grid; } +.today-product-card { + grid-template-rows: auto 1fr; + height: 100%; +} +.today-product-media { + width: 100%; + height: auto; + min-height: 150px; + aspect-ratio: 4 / 3; + padding: 9px; + overflow: hidden; + background: linear-gradient(145deg, var(--vd-surface-raised), var(--vd-surface-muted)); +} +.today-product-media img { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + padding: 0; + margin: auto; + object-fit: contain; + object-position: center; +} +.today-product-copy { align-content: start; } +.today-product-copy > strong { + min-height: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +@media (max-width: 1550px) { + .dashboard-chart-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-line-card { grid-column: 1 / -1; } + .dashboard-chart-grid > .dashboard-card:nth-child(2), + .dashboard-chart-grid > .dashboard-card:nth-child(3) { grid-column: auto; } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +} + +@media (max-width: 1180px) { + .dashboard-chart-grid { grid-template-columns: 1fr; } + .dashboard-chart-grid > .dashboard-card { grid-column: auto !important; } + .dashboard-category-chart { grid-template-columns: minmax(130px, 32%) minmax(0, 1fr); } + .today-ready-grid { grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); } +} + +@media (max-width: 760px) { + .dashboard-kpis { grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); } + .dashboard-kpi { min-height: 126px; } + .dashboard-category-chart { grid-template-columns: 1fr; justify-items: center; } + .dashboard-category-legend { width: 100%; } + .dashboard-line-card .dashboard-chart { height: 220px; overflow: hidden; } + .dashboard-line-card svg { min-width: 0 !important; width: 100% !important; } + .today-ready-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; padding-inline: 9px; } + .today-product-media { min-height: 125px; padding: 7px; } +} + +@media (max-width: 520px) { + .dashboard-kpis { grid-template-columns: 1fr; } + .dashboard-kpi { + min-height: 112px; + grid-template-columns: 26px minmax(0, 1fr) minmax(90px, 34%); + grid-template-rows: auto; + align-items: center; + } + .dashboard-kpi-icon { grid-column: 1; grid-row: 1; } + .dashboard-kpi > div { grid-column: 2; grid-row: 1; } + .dashboard-kpi svg { grid-column: 3; grid-row: 1; height: 44px; } + .today-ready-grid { grid-template-columns: 1fr; } + .today-product-card { + display: grid; + grid-template-columns: minmax(112px, 36%) minmax(0, 1fr); + grid-template-rows: 1fr; + } + .today-product-media { + height: 100%; + min-height: 125px; + aspect-ratio: auto; + } +} +.today-image-placeholder { + display: grid; + place-items: center; + width: 100%; + height: 100%; + color: var(--vd-ink-muted); + font-size: 9px; + text-align: center; +} +@media (max-width: 760px) and (min-width: 521px) { + .today-product-card { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } +} + +/* Vendoo Brand Asset Integration */ +.sidebar-logo{width:100%;display:flex;align-items:center;justify-content:flex-start;min-width:0} +.brand-logo-full{display:block;width:124px;max-width:100%;height:auto;object-fit:contain;object-position:left center} +.brand-logo-icon{display:none;width:34px;height:34px;object-fit:contain} +@media(max-width:1024px){ + .sidebar-logo{justify-content:center} + .brand-logo-full{display:none} + .brand-logo-icon{display:block} +} + +/* --- AI Model Photos --- */ +.ai-model-card { + margin-top: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + display: grid; + gap: 12px; +} + +.ai-model-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.ai-model-card-head > div:first-child { + display: flex; + gap: 12px; +} + +.ai-model-card h4 { + margin: 0 0 4px; + font-size: 15px; +} + +.ai-model-card p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.ai-model-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} + +.ai-model-controls label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.ai-model-controls .small-btn { + height: 40px; + justify-content: center; +} + +.ai-model-status { + font-size: 12px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + color: var(--text); +} + +.ai-model-status.is-working { border-color: color-mix(in srgb, var(--vd-warning) 38%, var(--line)); } +.ai-model-status.is-success { border-color: color-mix(in srgb, var(--vd-success) 42%, var(--line)); } +.ai-model-status.is-danger { border-color: color-mix(in srgb, var(--vd-danger) 42%, var(--line)); } + +.ai-model-grid { + display: grid; + gap: 12px; +} + +.ai-model-grid-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.ai-model-grid-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.ai-model-shot-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 8px; + background: var(--surface); + display: grid; + gap: 8px; +} + +.ai-model-shot-card img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 5px; + border: 1px solid var(--line); + background: #fff; +} + +.ai-model-shot-copy { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.ai-model-shot-copy strong, +.ai-model-shot-copy span { + font-size: 12px; +} + +.detail-ai-model-card { + margin-top: 14px; +} + +@media (max-width: 980px) { + .ai-model-controls { + grid-template-columns: 1fr; + } + + .ai-model-grid-head { + flex-direction: column; + align-items: stretch; + } +} + +/* Slice 15: AI provider settings */ +.settings-form { max-width: 920px; } +.ai-settings-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 16px; + background: var(--panel); +} +.ai-settings-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 14px; +} +.ai-settings-head h3 { + margin: 0 0 4px; + font-size: 16px; +} +.ai-settings-head p { + margin: 0; + color: var(--text-muted); + font-size: 12px; +} +.ai-settings-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 14px; +} +.ai-settings-grid > div { min-width: 0; } +.setting-inline-checks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin-bottom: 14px; +} +.setting-inline-checks label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.setting-inline-checks input { margin: 0; } +.inline-input-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.compact-inline { + white-space: nowrap; + height: 40px; +} +.ai-models-status { + font-size: 12px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 10px 12px; + color: var(--text); +} +.openrouter-models-preview { + margin-top: 12px; + display: grid; + gap: 10px; +} +.openrouter-model-card { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 12px; + display: grid; + gap: 8px; +} +.openrouter-model-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} +.openrouter-model-top strong { + display: block; + font-size: 13px; +} +.openrouter-model-top span { + display: block; + color: var(--text-muted); + font-size: 12px; + margin-top: 2px; +} +.openrouter-model-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.openrouter-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 5px; + font-size: 11px; + border: 1px solid var(--line); + background: var(--panel); +} +.openrouter-badge.is-free { + border-color: color-mix(in srgb, var(--success) 45%, var(--line)); +} +.openrouter-model-description { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} +@media (max-width: 960px) { + .ai-settings-grid, + .setting-inline-checks { + grid-template-columns: 1fr; + } + .inline-input-actions { + grid-template-columns: 1fr; + } +} + + +.ai-generation-history-wrap { + display: grid; + gap: 10px; +} +.ai-generation-history-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.ai-generation-history-head strong, +.ai-generation-history-head span, +.ai-generation-empty { + font-size: 12px; +} +.ai-generation-history-head span, +.ai-generation-empty { color: var(--muted); } +.ai-generation-history { + display: grid; + gap: 10px; +} +.ai-generation-card { + border: 1px solid var(--line); + border-radius: 5px; + padding: 10px; + background: var(--surface); + display: grid; + gap: 10px; +} +.ai-generation-card header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.ai-generation-card header strong, +.ai-generation-card header span { + font-size: 12px; +} +.ai-generation-mini-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.ai-generation-thumb { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + padding: 0; + overflow: hidden; + cursor: pointer; +} +.ai-generation-thumb img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} + +/* Slice 17: robust mobile navigation */ +.sidebar-close { + display: none; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface-muted); + color: var(--vd-ink); + cursor: pointer; +} + +@media (max-width: 768px) { + body.mobile-nav-open { + overflow: hidden; + touch-action: none; + } + + .sidebar { + display: flex; + width: min(86vw, 310px) !important; + max-width: 310px; + transform: translate3d(-105%, 0, 0); + transition: transform .22s ease; + z-index: 320; + box-shadow: none; + overflow-y: auto; + visibility: visible; + pointer-events: none; + } + + .sidebar.open { + transform: translate3d(0, 0, 0); + box-shadow: 10px 0 34px rgba(23, 23, 20, .24); + pointer-events: auto; + } + + .sidebar-header { + min-height: var(--vd-topbar-height); + height: auto; + padding: 10px 12px 10px 16px; + justify-content: space-between !important; + gap: 12px; + } + + .sidebar-logo { + justify-content: flex-start !important; + min-width: 0; + } + + .brand-logo-full { + display: block !important; + max-width: 150px; + height: 34px; + object-fit: contain; + object-position: left center; + } + + .brand-logo-icon { + display: none !important; + } + + .sidebar-close { + display: inline-flex; + flex: 0 0 auto; + } + + .sidebar-nav { + padding: 12px 10px 8px; + } + + .nav-item, + .nav-item-quiet { + justify-content: flex-start !important; + min-height: 44px; + padding: 0 12px !important; + gap: 12px; + font-size: 14px; + } + + .nav-item span, + .nav-group-label, + .sidebar-footer .theme-toggle span, + .sidebar-user-info { + display: block !important; + } + + .nav-item .nav-badge { + display: inline-flex !important; + flex: 0 0 auto; + margin-left: auto; + } + + .sidebar-footer .theme-toggle { + justify-content: flex-start !important; + padding: 0 10px !important; + } + + .sidebar-user { + text-align: left; + } + + .sidebar-overlay { + position: fixed; + inset: 0; + z-index: 310; + display: block; + opacity: 0; + visibility: hidden; + pointer-events: none; + background: rgba(20, 20, 18, .46); + backdrop-filter: blur(2px); + transition: opacity .18s ease, visibility .18s ease; + } + + .sidebar-overlay.show { + display: block; + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + .sidebar-toggle { + display: inline-flex !important; + width: 38px; + height: 38px; + align-items: center; + justify-content: center; + border: 1px solid var(--vd-line); + border-radius: 5px; + background: var(--vd-surface); + position: relative; + z-index: 2; + } +} + +/* Slice 20: Local FLUX full installer */ +.local-flux-install-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.local-flux-install-options label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text); +} +.local-flux-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.local-flux-progress { + display: grid; + gap: 8px; + margin-top: 12px; +} +.local-flux-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} +.local-flux-progress-track { + height: 10px; + border-radius: 5px; + background: var(--surface); + border: 1px solid var(--line); + overflow: hidden; +} +.local-flux-progress-track > div { + height: 100%; + width: 0; + background: var(--accent); + transition: width .25s ease; +} +@media (max-width: 980px) { + .local-flux-install-options { grid-template-columns: 1fr; } + .local-flux-actions { display: grid; grid-template-columns: 1fr 1fr; } +} +@media (max-width: 620px) { + .local-flux-actions { grid-template-columns: 1fr; } +} + +/* Slice 21: detailed FLUX installation status */ +.local-flux-status-panel { + margin-top: 12px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + padding: 12px; + display: grid; + gap: 12px; +} +.local-flux-status-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.local-flux-status-head > div { + display: grid; + gap: 3px; +} +.local-flux-status-head strong { font-size: 13px; } +.local-flux-status-head span { + font-size: 11px; + color: var(--muted); +} +.local-flux-checks { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.local-flux-check { + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); + padding: 9px; + display: grid; + gap: 4px; + min-width: 0; +} +.local-flux-check strong { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.local-flux-check span { + font-size: 11px; + color: var(--muted); +} +.local-flux-check.is-ok { + border-color: color-mix(in srgb, var(--vd-success) 42%, var(--line)); +} +.local-flux-check.is-missing { + border-color: color-mix(in srgb, var(--vd-warning) 40%, var(--line)); +} +.local-flux-error { + border: 1px solid color-mix(in srgb, var(--vd-danger) 45%, var(--line)); + background: color-mix(in srgb, var(--vd-danger) 7%, var(--panel)); + border-radius: 5px; + padding: 10px; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; +} +.local-flux-log-details { + border-top: 1px solid var(--line); + padding-top: 8px; +} +.local-flux-log-details summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.local-flux-log-details pre { + margin: 10px 0 0; + max-height: 260px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 5px; + padding: 10px; + background: var(--panel); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 1100px) { + .local-flux-checks { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .local-flux-checks { grid-template-columns: 1fr; } + .local-flux-status-head { flex-direction: column; } +} + +/* Slice 22: live FLUX installer telemetry */ +.local-flux-live-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.local-flux-live-metrics > div { + border: 1px solid var(--line); + border-radius: 5px; + padding: 9px 10px; + background: var(--panel); + display: grid; + gap: 3px; + min-width: 0; +} +.local-flux-live-metrics span { + color: var(--text-muted); + font-size: 11px; +} +.local-flux-live-metrics strong { + font-size: 12px; + overflow-wrap: anywhere; +} +.local-flux-state-value.is-running { color: var(--accent); } +.local-flux-state-value.is-completed { color: var(--success); } +.local-flux-state-value.is-failed, +.local-flux-state-value.is-stalled { color: var(--danger); } +.local-flux-progress-track > div.is-stalled { + animation: local-flux-pulse 1s ease-in-out infinite alternate; +} +.local-flux-progress-track > div.is-failed { + background: var(--danger); +} +@keyframes local-flux-pulse { + from { opacity: .45; } + to { opacity: 1; } +} +@media (max-width: 980px) { + .local-flux-live-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .local-flux-live-metrics { grid-template-columns: 1fr; } +} + +/* Slice 23: Settings Command Center + real AI image progress */ +.settings-workspace { + padding-bottom: 96px; +} +.settings-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 24px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 10px; + background: + radial-gradient(circle at 92% 16%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 38%), + linear-gradient(135deg, var(--panel), var(--surface)); + overflow: hidden; +} +.settings-hero-copy { max-width: 720px; } +.settings-eyebrow { + display: inline-block; + margin-bottom: 6px; + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.settings-hero h2 { margin: 0 0 8px; font-size: clamp(24px, 3vw, 38px); letter-spacing: -.04em; } +.settings-hero p { margin: 0; color: var(--text-muted); line-height: 1.55; } +.settings-hero-status { display: grid; grid-template-columns: repeat(2, minmax(150px, 1fr)); gap: 10px; min-width: min(390px, 100%); } +.settings-status-chip { + display: flex; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--panel) 90%, transparent); +} +.settings-status-chip > div { display: grid; gap: 2px; } +.settings-status-chip small { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.settings-status-chip strong { font-size: 12px; } +.settings-status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-muted) 12%, transparent); } +.settings-status-chip.is-running .settings-status-dot { background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); } +.settings-status-chip.is-stopped .settings-status-dot { background: var(--warning); box-shadow: 0 0 0 5px color-mix(in srgb, var(--warning) 14%, transparent); } +.settings-status-chip.is-disabled .settings-status-dot, +.settings-status-chip.is-not-installed .settings-status-dot { background: var(--text-muted); } + +.settings-shell { + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + gap: 18px; + align-items: start; +} +.settings-nav { + position: sticky; + top: 14px; + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.settings-nav-item { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 10px; + align-items: center; + width: 100%; + min-height: 62px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + transition: .18s ease; +} +.settings-nav-item:hover { background: var(--surface); border-color: var(--line); } +.settings-nav-item.active { background: color-mix(in srgb, var(--accent) 10%, var(--surface)); border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); } +.settings-nav-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 7px; background: var(--surface); color: var(--accent); font-size: 16px; } +.settings-nav-item.active .settings-nav-icon { background: var(--accent); color: white; } +.settings-nav-item > span:last-child { display: grid; gap: 2px; min-width: 0; } +.settings-nav-item strong { font-size: 13px; } +.settings-nav-item small { color: var(--text-muted); font-size: 10px; } + +.settings-panels { min-width: 0; } +.settings-panel { display: none; gap: 14px; } +.settings-panel.active { display: grid; } +.settings-panel-head { padding: 4px 2px 2px; } +.settings-panel-head > div { display: grid; grid-template-columns: 36px minmax(0,1fr); column-gap: 10px; align-items: center; } +.settings-panel-head span { grid-row: 1 / 3; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--accent); font-size: 11px; font-weight: 800; } +.settings-panel-head h3 { margin: 0; font-size: 20px; letter-spacing: -.02em; } +.settings-panel-head p { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; } + +.settings-card, +.flux-command-card { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + padding: 18px; + box-shadow: 0 8px 28px rgba(0,0,0,.035); +} +.settings-card-grid { display: grid; gap: 14px; } +.settings-card-grid.two-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.settings-card-grid.three-cols { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.settings-field-grid { display: grid; gap: 12px; } +.settings-field-grid.four-cols { grid-template-columns: repeat(4, minmax(0,1fr)); } +.settings-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.settings-card-head > div { display: grid; gap: 3px; } +.settings-card-head strong { font-size: 14px; } +.settings-card-head small { color: var(--text-muted); font-size: 11px; line-height: 1.4; } +.settings-card-head.split { align-items: center; } +.settings-card .setting-group { margin: 0; } +.settings-card .setting-group + .setting-group { margin-top: 12px; } +.compact-top { max-width: 520px; margin-top: 14px !important; } +.full-span { grid-column: 1 / -1; } +.checkbox-line { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font-size: 12px; } +.checkbox-line input { width: 16px; height: 16px; accent-color: var(--accent); } + +.premium-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; } +.premium-switch input { position: absolute; opacity: 0; pointer-events: none; } +.premium-switch span { position: relative; width: 42px; height: 23px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); transition: .2s ease; } +.premium-switch span::after { content: ''; position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: .2s ease; } +.premium-switch input:checked + span { background: color-mix(in srgb, var(--success) 18%, var(--surface)); border-color: color-mix(in srgb, var(--success) 55%, var(--line)); } +.premium-switch input:checked + span::after { left: 22px; background: var(--success); } +.premium-switch b { font-size: 12px; } +.premium-switch.large span { width: 54px; height: 29px; } +.premium-switch.large span::after { width: 21px; height: 21px; } +.premium-switch.large input:checked + span::after { left: 28px; } + +.reference-control-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + margin: 16px 0; + padding: 15px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 9px; + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); +} +.reference-control-copy { display: flex; align-items: flex-start; gap: 12px; } +.reference-control-copy > div { display: grid; gap: 4px; } +.reference-control-copy strong { font-size: 13px; } +.reference-control-copy small { color: var(--text-muted); font-size: 11px; line-height: 1.45; } +.reference-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 7px; background: var(--accent); color: white; } +.reference-strength-row { grid-column: 1 / -1; display: grid; grid-template-columns: auto minmax(140px, 1fr) 46px; align-items: center; gap: 10px; } +.reference-strength-row label, .reference-strength-row output { font-size: 11px; font-weight: 700; } +.reference-strength-row input[type="range"] { width: 100%; accent-color: var(--accent); } +.reference-strength-row output { text-align: right; color: var(--accent); } +.modern-checks { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; } +.modern-checks label { min-height: 44px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); font-size: 11px; } +.modern-checks input { accent-color: var(--accent); } + +.settings-disclosure { border: 1px solid var(--line); border-radius: 9px; background: var(--surface); overflow: hidden; } +.settings-disclosure summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; cursor: pointer; list-style: none; } +.settings-disclosure summary::-webkit-details-marker { display: none; } +.settings-disclosure summary span { font-size: 12px; font-weight: 700; } +.settings-disclosure summary small { color: var(--text-muted); font-size: 10px; } +.settings-disclosure[open] summary { border-bottom: 1px solid var(--line); } +.disclosure-body { padding: 16px; } + +.flux-command-card { + display: grid; + grid-template-columns: minmax(0,1fr) auto; + gap: 16px; + align-items: center; + background: + radial-gradient(circle at 8% 20%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%), + var(--panel); +} +.flux-command-main { display: flex; align-items: center; gap: 16px; min-width: 0; } +.flux-orb { display: grid; place-items: center; width: 72px; height: 72px; flex: 0 0 auto; border-radius: 22px; background: var(--surface); border: 1px solid var(--line); color: var(--text-muted); font-size: 11px; font-weight: 900; letter-spacing: .08em; box-shadow: inset 0 0 24px rgba(0,0,0,.06); } +.flux-orb.is-running { color: white; background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 58%, #6c5ce7)); border-color: transparent; box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 10%, transparent), 0 12px 30px color-mix(in srgb, var(--accent) 24%, transparent); } +.flux-orb.is-stopped { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 45%, var(--line)); } +.flux-command-copy { min-width: 0; } +.flux-command-copy > span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } +.flux-command-copy h3 { margin: 4px 0 5px; font-size: 22px; } +.flux-command-copy p { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.45; } +.flux-command-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; } +.flux-health-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; } +.flux-health-grid > div { display: grid; gap: 5px; min-height: 72px; padding: 13px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); } +.flux-health-grid span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } +.flux-health-grid strong { font-size: 13px; } +.flux-state-badge { display: inline-flex; align-items: center; min-height: 27px; padding: 0 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-size: 10px; font-weight: 800; } +.flux-state-badge.is-running { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--line)); background: color-mix(in srgb, var(--success) 8%, var(--surface)); } +.flux-state-badge.is-stopped { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 45%, var(--line)); } +.flux-state-badge.is-disabled, +.flux-state-badge.is-not-installed { color: var(--text-muted); } +.command-actions { margin-top: 14px; } + +.settings-savebar { + position: sticky; + bottom: 12px; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 18px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 92%, transparent); + backdrop-filter: blur(14px); + box-shadow: 0 12px 32px rgba(0,0,0,.12); +} +.settings-savebar > div { display: grid; gap: 2px; } +.settings-savebar strong { font-size: 12px; } +.settings-savebar small { color: var(--text-muted); font-size: 10px; } + +.ai-job-progress-card { display: grid !important; gap: 8px; padding: 12px !important; } +.ai-job-progress-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.ai-job-progress-head strong { font-size: 12px; line-height: 1.35; } +.ai-job-progress-head span { color: var(--accent); font-size: 12px; font-weight: 800; } +.ai-job-progress-track { height: 9px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); overflow: hidden; } +.ai-job-progress-track > div { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #8e77ff)); transition: width .35s ease; } +.ai-job-progress-card small { color: var(--text-muted); font-size: 10px; } + +@media (max-width: 1180px) { + .settings-shell { grid-template-columns: 1fr; } + .settings-nav { position: static; grid-template-columns: repeat(4, minmax(0,1fr)); } + .settings-nav-item { grid-template-columns: 30px minmax(0,1fr); } + .settings-field-grid.four-cols { grid-template-columns: repeat(2, minmax(0,1fr)); } + .settings-card-grid.three-cols { grid-template-columns: 1fr; } +} +@media (max-width: 820px) { + .settings-hero { align-items: flex-start; flex-direction: column; } + .settings-hero-status { width: 100%; min-width: 0; } + .settings-nav { display: flex; overflow-x: auto; } + .settings-nav-item { flex: 0 0 190px; } + .settings-card-grid.two-cols, + .settings-field-grid.four-cols, + .modern-checks, + .flux-health-grid { grid-template-columns: 1fr; } + .flux-command-card { grid-template-columns: 1fr; } + .flux-command-card > .premium-switch { justify-self: start; } +} +@media (max-width: 560px) { + .settings-hero { padding: 18px; } + .settings-hero-status { grid-template-columns: 1fr; } + .settings-card, .flux-command-card { padding: 14px; } + .reference-control-card { grid-template-columns: 1fr; } + .reference-strength-row { grid-template-columns: 1fr auto; } + .reference-strength-row input { grid-column: 1 / -1; grid-row: 2; } + .settings-savebar { align-items: stretch; flex-direction: column; } + .settings-savebar button { width: 100%; } + .flux-command-main { align-items: flex-start; } + .flux-orb { width: 58px; height: 58px; border-radius: 18px; } +} + + +body.ai-prompt-open { overflow: hidden; } +.ai-prompt-modal { + position: fixed; + inset: 0; + z-index: 10020; + display: grid; + place-items: center; + padding: 20px; +} +.ai-prompt-backdrop { + position: absolute; + inset: 0; + background: rgba(22, 22, 19, 0.56); + backdrop-filter: blur(8px); +} +.ai-prompt-shell { + position: relative; + width: min(760px, 96vw); + max-height: min(86vh, 780px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--vd-line); + border-radius: 18px; + background: var(--vd-surface-raised); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); +} +.ai-prompt-header, +.ai-prompt-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 20px; +} +.ai-prompt-header { border-bottom: 1px solid var(--vd-line); } +.ai-prompt-header h2 { margin: 0; font-size: 20px; } +.ai-prompt-header p { margin: 4px 0 0; color: var(--vd-ink-muted); font-size: 12px; } +.ai-prompt-body { padding: 20px; overflow: auto; display: grid; gap: 16px; } +.ai-prompt-summary { display: flex; flex-wrap: wrap; gap: 10px; } +.ai-prompt-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--vd-line); + border-radius: 999px; + background: var(--vd-surface); + font-size: 12px; + font-weight: 700; +} +.ai-prompt-field { display: grid; gap: 8px; } +.ai-prompt-field > span { font-weight: 700; } +.ai-prompt-field textarea { + width: 100%; + resize: vertical; + min-height: 160px; + padding: 14px; + border: 1px solid var(--vd-line); + border-radius: 12px; + background: var(--vd-surface); + color: var(--vd-ink); + font: inherit; +} +.ai-prompt-field small { color: var(--vd-ink-muted); } +.ai-prompt-check { + padding: 12px 14px; + border: 1px solid var(--vd-line); + border-radius: 12px; + background: var(--vd-surface); +} +.ai-prompt-footer-actions { display: flex; align-items: center; gap: 10px; } +@media (max-width: 760px) { + .ai-prompt-shell { width: 100%; } + .ai-prompt-header, .ai-prompt-footer { padding: 14px; } + .ai-prompt-body { padding: 14px; } + .ai-prompt-footer { flex-direction: column; align-items: stretch; } + .ai-prompt-footer-actions { width: 100%; justify-content: stretch; } + .ai-prompt-footer-actions > .vd-button, + .ai-prompt-footer > .vd-button { flex: 1 1 auto; } +} + +/* Slice 28: Eigenständiges FLUX Studio */ +.flux-studio-page { padding-bottom: 36px; } +.flux-studio-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; } +.flux-studio-engine-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex-wrap:wrap; } +.flux-studio-layout { display:grid; grid-template-columns:minmax(340px,.88fr) minmax(420px,1.12fr); gap:18px; align-items:start; } +.flux-studio-card { min-width:0; padding:20px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-sm); } +.flux-studio-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:16px; } +.flux-studio-card-head>div { display:flex; align-items:center; gap:9px; min-width:0; } +.flux-studio-card-head>div>span { display:grid; place-items:center; width:26px; height:26px; flex:0 0 auto; border-radius:8px; background:var(--vd-brand-soft); color:var(--vd-brand); font-size:10px; font-weight:850; } +.flux-studio-card-head h3 { margin:0; font-size:15px; } +.flux-studio-card-head small { color:var(--vd-ink-muted); font-size:10px; text-align:right; } +.flux-studio-prompt-field { display:grid; gap:8px; } +.flux-studio-prompt-field>span { font-size:11px; font-weight:800; } +.flux-studio-prompt-field textarea { width:100%; min-height:210px; resize:vertical; padding:14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); color:var(--vd-ink); font:inherit; line-height:1.55; } +.flux-studio-prompt-field textarea:focus { outline:2px solid color-mix(in srgb,var(--vd-brand) 28%,transparent); border-color:var(--vd-brand); } +.flux-studio-prompt-field small { justify-self:end; color:var(--vd-ink-muted); font-size:9px; } +.flux-studio-template-row { display:flex; gap:7px; flex-wrap:wrap; margin:12px 0 16px; } +.flux-studio-template-row button { min-height:30px; padding:6px 10px; border:1px solid var(--vd-line); border-radius:999px; background:var(--vd-surface); color:var(--vd-ink); font-size:9px; font-weight:750; cursor:pointer; } +.flux-studio-template-row button:hover { border-color:var(--vd-brand); color:var(--vd-brand); } +.flux-studio-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } +.flux-studio-fields label { display:grid; gap:6px; font-size:10px; font-weight:750; } +.flux-studio-fields select,.flux-studio-fields input { width:100%; min-height:38px; } +.flux-seed-input { display:grid; grid-template-columns:minmax(0,1fr) 38px; gap:6px; } +.flux-seed-input button { border:1px solid var(--vd-line); border-radius:9px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; font-size:17px; } +.flux-studio-run-row { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:18px; } +#flux-studio-status { min-width:0; color:var(--vd-ink-muted); font-size:10px; text-align:right; } +#flux-studio-status[data-tone="success"] { color:var(--vd-success); } +#flux-studio-status[data-tone="error"] { color:var(--vd-danger); } +.flux-studio-progress { display:grid; gap:7px; margin-top:14px; } +.flux-studio-progress>div:first-child { display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--vd-ink-muted); font-size:10px; } +#flux-studio-progress-bar { width:0; height:100%; background:var(--vd-brand); transition:width .25s ease; } +.flux-studio-preview-card { position:sticky; top:18px; } +.flux-studio-preview { min-height:520px; display:grid; place-items:center; overflow:hidden; border:1px solid var(--vd-line); border-radius:14px; background:linear-gradient(135deg,var(--vd-surface-muted),var(--vd-surface)); } +.flux-studio-preview img { display:block; width:100%; max-height:680px; object-fit:contain; } +.flux-studio-preview-empty { display:grid; justify-items:center; gap:7px; padding:34px; text-align:center; color:var(--vd-ink-muted); } +.flux-studio-preview-empty b { font-size:34px; color:var(--vd-brand); } +.flux-studio-preview-empty strong { color:var(--vd-ink); font-size:14px; } +.flux-studio-preview-empty span { font-size:10px; } +.flux-studio-preview-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px; } +.flux-studio-preview-actions .danger { margin-left:auto; } +.flux-studio-history-section { margin-top:22px; } +.flux-studio-history-head { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; margin-bottom:13px; } +.flux-studio-history-head h3 { margin:2px 0 0; } +.flux-studio-history { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; } +.flux-history-card { min-width:0; overflow:hidden; border:1px solid var(--vd-line); border-radius:14px; background:var(--vd-surface-raised); transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease; } +.flux-history-card:hover { transform:translateY(-2px); box-shadow:var(--vd-shadow-sm); } +.flux-history-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 12%,transparent); } +.flux-history-image { width:100%; height:190px; padding:0; border:0; background:var(--vd-surface-muted); cursor:pointer; } +.flux-history-image img { display:block; width:100%; height:100%; object-fit:cover; } +.flux-history-copy { display:grid; gap:5px; padding:11px 12px 7px; } +.flux-history-copy strong { overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; font-size:10px; line-height:1.4; } +.flux-history-copy small { color:var(--vd-ink-muted); font-size:8px; } +.flux-history-actions { display:flex; gap:6px; padding:7px 12px 12px; } +.flux-history-actions .small-btn { min-height:28px; padding:5px 8px; font-size:8px; } +.flux-history-actions .danger { color:var(--vd-danger); } +@media(max-width:1180px){.flux-studio-layout{grid-template-columns:1fr}.flux-studio-preview-card{position:static}.flux-studio-history{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:820px){.flux-studio-heading{flex-direction:column}.flux-studio-engine-actions{justify-content:flex-start}.flux-studio-history{grid-template-columns:repeat(2,minmax(0,1fr))}.flux-studio-preview{min-height:380px}} +@media(max-width:560px){.flux-studio-card{padding:14px}.flux-studio-fields{grid-template-columns:1fr}.flux-studio-run-row{align-items:stretch;flex-direction:column}.flux-studio-run-row .vd-button{width:100%}#flux-studio-status{text-align:left}.flux-studio-preview-actions{display:grid;grid-template-columns:1fr}.flux-studio-preview-actions .danger{margin-left:0}.flux-studio-history{grid-template-columns:1fr}.flux-history-image{height:240px}.flux-studio-engine-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.flux-studio-engine-actions .settings-status-chip{grid-column:1/-1}} + + +/* Slice 29: Top-Tabs, Theme Polish und optimiertes FLUX Studio */ +.settings-workspace { --settings-radius: 16px; } +.settings-hero { border-radius: var(--settings-radius); padding: 22px 24px; box-shadow: var(--vd-shadow-sm); } +.settings-shell { display: block; } +.settings-nav { + position: sticky; top: 10px; z-index: 15; + display: flex; gap: 6px; width: 100%; margin-bottom: 18px; padding: 6px; + overflow-x: auto; scrollbar-width: thin; + border: 1px solid var(--line); border-radius: 14px; + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: 0 10px 30px rgba(0,0,0,.08); backdrop-filter: blur(18px); +} +.settings-nav-item { + flex: 1 1 0; min-width: 150px; min-height: 54px; + grid-template-columns: 32px minmax(0,1fr); padding: 8px 12px; + border: 1px solid transparent; border-radius: 10px; +} +.settings-nav-item:hover { transform: translateY(-1px); } +.settings-nav-item.active { background: var(--panel); box-shadow: 0 7px 20px rgba(0,0,0,.09); } +.settings-nav-item small { display: none; } +.settings-nav-icon { width: 30px; height: 30px; border-radius: 9px; } +.settings-panel { animation: settings-panel-in .22s ease both; } +@keyframes settings-panel-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } } +.settings-panel-head { padding: 6px 2px 10px; border-bottom: 1px solid var(--line); margin-bottom: 2px; } +.settings-card, .flux-command-card, .local-flux-status-panel, .settings-disclosure { + border-radius: 14px; border-color: color-mix(in srgb, var(--line) 88%, var(--accent)); + box-shadow: 0 8px 24px rgba(0,0,0,.045); +} +.settings-card:hover { border-color: color-mix(in srgb, var(--accent) 24%, var(--line)); } +.settings-savebar { position: sticky; bottom: 12px; z-index: 20; border-radius: 14px; } +.settings-inline-note { display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding:11px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--text-muted); font-size:10px; line-height:1.45; } +.settings-inline-note strong { color:var(--accent); } + +.flux-studio-heading { padding: 22px 24px; margin-bottom: 18px; border: 1px solid var(--vd-line); border-radius: 18px; background: radial-gradient(circle at 88% 12%, color-mix(in srgb,var(--vd-brand) 18%,transparent), transparent 34%), linear-gradient(135deg,var(--vd-surface-raised),var(--vd-surface)); box-shadow:var(--vd-shadow-sm); } +.flux-studio-card { border-radius:18px; } +.flux-studio-controls { background: linear-gradient(180deg, color-mix(in srgb,var(--vd-brand) 3%,var(--vd-surface-raised)), var(--vd-surface-raised)); } +.flux-prompt-toolbar { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:12px 0 10px; } +.flux-prompt-toolbar .flux-studio-template-row { margin:0; } +.flux-optimize-button { flex:0 0 auto; min-height:32px; color:var(--vd-brand); border-color:color-mix(in srgb,var(--vd-brand) 38%,var(--vd-line)); } +.flux-prompt-quality { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:15px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); } +.flux-prompt-quality > span { display:flex; align-items:center; gap:7px; flex:0 0 auto; font-size:9px; } +.flux-prompt-quality i { width:8px; height:8px; border-radius:50%; background:var(--vd-ink-muted); box-shadow:0 0 0 4px color-mix(in srgb,var(--vd-ink-muted) 12%,transparent); } +.flux-prompt-quality p { margin:0; color:var(--vd-ink-muted); font-size:9px; text-align:right; } +.flux-prompt-quality[data-level="weak"] i { background:var(--vd-danger); } +.flux-prompt-quality[data-level="good"] i { background:#d99a24; } +.flux-prompt-quality[data-level="strong"] i { background:var(--vd-success); } +.flux-profile-strip { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin-bottom:14px; } +.flux-profile-strip button { display:grid; gap:3px; min-width:0; padding:10px; text-align:left; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; transition:.18s ease; } +.flux-profile-strip button:hover { transform:translateY(-1px); border-color:var(--vd-brand); } +.flux-profile-strip button.active { border-color:var(--vd-brand); background:var(--vd-brand-soft); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 10%,transparent); } +.flux-profile-strip strong { font-size:10px; } +.flux-profile-strip small { color:var(--vd-ink-muted); font-size:8px; line-height:1.35; } +.flux-studio-preview { position:relative; background-image: linear-gradient(45deg,color-mix(in srgb,var(--vd-line) 35%,transparent) 25%,transparent 25%),linear-gradient(-45deg,color-mix(in srgb,var(--vd-line) 35%,transparent) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,color-mix(in srgb,var(--vd-line) 35%,transparent) 75%),linear-gradient(-45deg,transparent 75%,color-mix(in srgb,var(--vd-line) 35%,transparent) 75%); background-size:20px 20px; background-position:0 0,0 10px,10px -10px,-10px 0; } +.flux-studio-preview img { position:relative; z-index:1; background:var(--vd-surface); } +.flux-studio-preview-actions { padding-top:12px; border-top:1px solid var(--vd-line); } +.flux-studio-preview-actions .primary { box-shadow:0 7px 20px color-mix(in srgb,var(--vd-brand) 22%,transparent); } +.flux-history-card { border-radius:16px; } + +@media(max-width:900px){ + .settings-nav-item { flex:0 0 165px; } + .flux-profile-strip { grid-template-columns:repeat(2,minmax(0,1fr)); } + .flux-prompt-toolbar { align-items:stretch; flex-direction:column; } + .flux-optimize-button { width:100%; } +} +@media(max-width:560px){ + .settings-nav { top:6px; padding:5px; } + .settings-nav-item { flex-basis:138px; min-width:138px; min-height:48px; padding:7px 9px; } + .settings-nav-icon { display:none; } + .settings-nav-item { grid-template-columns:1fr; } + .flux-studio-heading { padding:17px; border-radius:14px; } + .flux-profile-strip { grid-template-columns:1fr 1fr; } + .flux-prompt-quality { align-items:flex-start; flex-direction:column; } + .flux-prompt-quality p { text-align:left; } +} + +/* Slice 30 · FLUX Studio Pro */ +.flux-studio-pro-layout { grid-template-columns:minmax(390px,.9fr) minmax(440px,1.1fr); } +.flux-studio-side-stack { display:grid; gap:18px; min-width:0; } +.flux-order-options { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; margin:14px 0; } +.flux-option-block { display:grid; gap:7px; } +.flux-option-label { font-size:10px; font-weight:800; color:var(--vd-ink); } +.flux-segmented { display:grid; grid-template-columns:repeat(3,1fr); gap:5px; padding:4px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-muted); } +.flux-segmented button { min-height:34px; border:0; border-radius:8px; background:transparent; color:var(--vd-ink-muted); font-size:10px; font-weight:800; cursor:pointer; } +.flux-segmented button:hover { color:var(--vd-ink); } +.flux-segmented button.active { background:var(--vd-surface-raised); color:var(--vd-brand); box-shadow:var(--vd-shadow-sm); } +.flux-lock-toggle { display:flex; align-items:center; gap:10px; min-height:58px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); cursor:pointer; } +.flux-lock-toggle input { width:18px; height:18px; accent-color:var(--vd-brand); } +.flux-lock-toggle span { display:grid; gap:2px; } +.flux-lock-toggle strong { font-size:10px; } +.flux-lock-toggle small { color:var(--vd-ink-muted); font-size:9px; line-height:1.35; } +.flux-indeterminate-track { position:relative; height:6px; overflow:hidden; border-radius:999px; background:var(--vd-surface-muted); } +.flux-indeterminate-track i { position:absolute; inset:0 auto 0 -35%; width:35%; border-radius:999px; background:var(--vd-brand); animation:flux-indeterminate 1.25s ease-in-out infinite; } +.flux-studio-progress[data-state="done"] .flux-indeterminate-track i { left:0; width:100%; animation:none; background:var(--vd-success); } +.flux-studio-progress[data-state="error"] .flux-indeterminate-track i,.flux-studio-progress[data-state="cancelled"] .flux-indeterminate-track i { left:0; width:100%; animation:none; background:var(--vd-danger); } +@keyframes flux-indeterminate { 0%{transform:translateX(0)} 50%{transform:translateX(285%)} 100%{transform:translateX(0)} } +.flux-variant-results { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin-bottom:12px; } +.flux-variant-tile { min-width:0; padding:4px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface); color:var(--vd-ink); cursor:pointer; text-align:left; } +.flux-variant-tile.active { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 18%,transparent); } +.flux-variant-tile img { width:100%; aspect-ratio:1/1; object-fit:cover; border-radius:8px; display:block; } +.flux-variant-tile span,.flux-variant-tile small { display:block; padding:4px 4px 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.flux-variant-tile span { font-size:9px; font-weight:800; } +.flux-variant-tile small { padding-top:1px; color:var(--vd-ink-muted); font-size:8px; } +.flux-runtime-card { position:relative; } +.flux-runtime-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } +.flux-runtime-grid>div { display:grid; gap:3px; min-width:0; padding:11px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); } +.flux-runtime-grid span { color:var(--vd-ink-muted); font-size:9px; font-weight:750; } +.flux-runtime-grid strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.flux-runtime-grid small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--vd-ink-muted); font-size:8px; } +.flux-job-center { margin-top:22px; padding:20px; border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-sm); } +.flux-job-center-head { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; margin-bottom:14px; } +.flux-job-center-head h3 { margin:2px 0 3px; } +.flux-job-center-head p:last-child { margin:0; color:var(--vd-ink-muted); font-size:10px; } +.flux-job-center-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; } +.flux-job-center-actions select { min-height:38px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface); color:var(--vd-ink); padding:0 10px; } +.flux-job-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; } +.flux-job-summary>div { display:grid; gap:3px; padding:11px 12px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface); } +.flux-job-summary span { color:var(--vd-ink-muted); font-size:9px; } +.flux-job-summary strong { font-size:16px; } +.flux-job-list { display:grid; gap:10px; } +.flux-job-card { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:14px; align-items:center; padding:14px; border:1px solid var(--vd-line); border-radius:14px; background:var(--vd-surface); } +.flux-job-card-main { min-width:0; } +.flux-job-status-line { display:flex; align-items:center; gap:8px; margin-bottom:7px; } +.flux-job-status-line small { color:var(--vd-ink-muted); font-size:8px; } +.flux-job-status { display:inline-flex; align-items:center; min-height:22px; padding:3px 8px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; font-weight:850; } +.flux-job-status.is-running,.flux-job-status.is-queued { background:color-mix(in srgb,var(--vd-brand) 12%,var(--vd-surface)); color:var(--vd-brand); } +.flux-job-status.is-completed { background:color-mix(in srgb,var(--vd-success) 13%,var(--vd-surface)); color:var(--vd-success); } +.flux-job-status.is-failed,.flux-job-status.is-cancelled { background:color-mix(in srgb,var(--vd-danger) 12%,var(--vd-surface)); color:var(--vd-danger); } +.flux-job-status.is-completed_with_errors,.flux-job-status.is-cancelling { background:color-mix(in srgb,#c88600 13%,var(--vd-surface)); color:#a66f00; } +.flux-job-prompt { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.flux-job-params { display:flex; gap:5px; flex-wrap:wrap; margin-top:7px; } +.flux-job-params span { padding:3px 7px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; } +.flux-job-card-main p { margin:7px 0 0; color:var(--vd-ink-muted); font-size:9px; line-height:1.45; } +.flux-job-card-main p b { color:var(--vd-danger); font-weight:700; } +.flux-job-variants { display:flex; gap:6px; flex-wrap:wrap; max-width:220px; justify-content:flex-end; } +.flux-job-thumb { position:relative; display:grid; place-items:center; width:48px; height:48px; padding:0; overflow:hidden; border:1px solid var(--vd-line); border-radius:9px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); cursor:pointer; } +.flux-job-thumb img { width:100%; height:100%; object-fit:cover; } +.flux-job-thumb>span { position:absolute; right:3px; bottom:3px; display:grid; place-items:center; min-width:16px; height:16px; border-radius:999px; background:rgba(0,0,0,.72); color:#fff; font-size:8px; } +.flux-job-thumb.is-placeholder { cursor:default; } +.flux-job-thumb.is-placeholder span { position:static; background:transparent; color:var(--vd-ink); font-weight:800; } +.flux-job-thumb.is-placeholder small { font-size:7px; } +.flux-job-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; max-width:190px; } +.flux-library-actions { display:flex; align-items:center; gap:10px; } +.flux-library-actions label { display:flex; align-items:center; gap:6px; color:var(--vd-ink-muted); font-size:9px; } +.flux-history-image { position:relative; } +.flux-history-image>span { position:absolute; top:7px; right:7px; color:#ffd15c; font-size:18px; text-shadow:0 1px 4px rgba(0,0,0,.45); } + +@media(max-width:1180px){ + .flux-studio-pro-layout{grid-template-columns:1fr} + .flux-job-card{grid-template-columns:minmax(0,1fr) auto} + .flux-job-actions{grid-column:1/-1;justify-content:flex-start;max-width:none} +} +@media(max-width:820px){ + .flux-order-options,.flux-runtime-grid{grid-template-columns:1fr} + .flux-job-center-head{align-items:stretch;flex-direction:column} + .flux-job-center-actions{justify-content:flex-start} + .flux-job-summary{grid-template-columns:repeat(2,minmax(0,1fr))} + .flux-job-card{grid-template-columns:1fr} + .flux-job-variants{justify-content:flex-start;max-width:none} + .flux-job-actions{grid-column:auto} +} +@media(max-width:560px){ + .flux-variant-results{grid-template-columns:repeat(2,minmax(0,1fr))} + .flux-job-center{padding:14px} + .flux-job-center-actions{display:grid;grid-template-columns:1fr} + .flux-job-center-actions>*{width:100%} + .flux-job-summary{grid-template-columns:1fr} + .flux-library-actions{align-items:flex-start;flex-direction:column} +} + +/* Slice 30 – sichtbares Variantenprotokoll im Job-Center */ +.flux-job-variant-ledger { display: grid; gap: .38rem; min-width: 0; } +.flux-job-variant-ledger > div { display: grid; grid-template-columns: 2.2rem minmax(6.5rem,auto) minmax(6rem,1fr) minmax(7rem,auto); align-items: center; gap: .5rem; padding: .42rem .55rem; border: 1px solid var(--border); border-radius: .65rem; background: color-mix(in srgb, var(--surface) 82%, transparent); font-size: .75rem; } +.flux-job-variant-ledger b { color: var(--text); } +.flux-job-variant-ledger span { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.flux-job-variant-ledger code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.flux-job-thumb small { display: block; max-width: 7.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 760px) { + .flux-job-variant-ledger > div { grid-template-columns: 2rem 1fr; } + .flux-job-variant-ledger code { grid-column: 1 / -1; } +} + + +/* Vendoo 1.23.2 · FLUX layout, collapsible jobs and editor tools */ +.flux-studio-left-stack { display:grid; gap:18px; min-width:0; align-content:start; } +.flux-studio-left-stack .flux-runtime-card { position:static; } +.flux-job-center-body.hidden { display:none !important; } +.flux-job-center-toggle { white-space:nowrap; } + +.flux-job-card { display:block; padding:0; overflow:hidden; } +.flux-job-card-toggle { + width:100%; display:flex; align-items:center; justify-content:space-between; gap:14px; + padding:12px 14px; border:0; color:var(--vd-ink); background:transparent; cursor:pointer; text-align:left; +} +.flux-job-card-toggle:hover { background:var(--vd-surface-muted); } +.flux-job-card-toggle-main { min-width:0; display:flex; align-items:center; gap:9px; flex:1; } +.flux-job-card-toggle-main .flux-job-prompt { min-width:0; } +.flux-job-card-toggle-meta { display:flex; align-items:center; gap:9px; flex:0 0 auto; color:var(--vd-ink-muted); font-size:8px; } +.flux-job-card-toggle-meta i { display:inline-grid; place-items:center; width:24px; height:24px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink); font-style:normal; font-size:15px; transition:transform .18s ease; } +.flux-job-card.is-expanded .flux-job-card-toggle-meta i { transform:rotate(180deg); } +.flux-job-card-body { + display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px 14px; align-items:start; + padding:0 14px 14px; border-top:1px solid var(--vd-line); +} +.flux-job-card.is-collapsed .flux-job-card-body { display:none; } +.flux-job-card-body .flux-job-card-main { padding-top:11px; } +.flux-job-card-body .flux-job-variants { padding-top:11px; } +.flux-job-card-body .flux-job-variant-ledger { grid-column:1/-1; } +.flux-job-card-body .flux-job-actions { grid-column:1/-1; max-width:none; justify-content:flex-start; } + +.pe-special-tools { gap:11px; } +.pe-tool-toggle { display:flex; align-items:flex-start; gap:9px; padding:9px; border:1px solid var(--vd-line, var(--border)); border-radius:8px; background:var(--vd-surface-muted, var(--bg-page)); cursor:pointer; } +.pe-tool-toggle input { width:17px; height:17px; margin-top:1px; accent-color:var(--vd-brand, var(--accent)); } +.pe-tool-toggle span { display:grid; gap:3px; min-width:0; } +.pe-tool-toggle strong { font-size:10px; color:var(--vd-ink, var(--text)); } +.pe-tool-toggle small { color:var(--vd-ink-muted, var(--text-muted)); font-size:8px; line-height:1.4; } +.pe-watermark-tools { display:grid; grid-template-columns:88px minmax(0,1fr); gap:8px; align-items:center; } +.pe-watermark-tools .pe-tool-toggle { grid-column:1/-1; } +.pe-watermark-tools > label:not(.pe-tool-toggle) { color:var(--vd-ink-muted, var(--text-muted)); font-size:10px; font-weight:700; } +.pe-control-body input[type="text"] { width:100%; min-height:34px; padding:7px 9px; border:1px solid var(--vd-line, var(--border)); border-radius:7px; color:var(--vd-ink, var(--text)); background:var(--vd-surface, var(--bg-input)); } + +@media(max-width:820px){ + .flux-job-card-toggle { align-items:flex-start; } + .flux-job-card-toggle-main { align-items:flex-start; flex-direction:column; } + .flux-job-card-toggle-meta { padding-top:1px; } + .flux-job-card-body { grid-template-columns:1fr; } + .flux-job-card-body .flux-job-variants,.flux-job-card-body .flux-job-variant-ledger,.flux-job-card-body .flux-job-actions { grid-column:auto; } +} + +/* ========================================================================== + Vendoo 1.24.0 · Responsive hardening, FLUX archive and media library + ========================================================================== */ + +/* Prevent any workspace child from forcing the application wider than viewport. */ +.main-wrapper, +main, +.tab-content, +.page-heading, +.history-workspace, +.history-table-shell, +.history-table, +.flux-studio-pro-layout, +.flux-studio-left-stack, +.flux-job-center, +.flux-job-center-body, +.media-library-page, +.media-toolbar, +.media-grid, +.settings-shell, +.publish-workspace, +.storage-main, +.admin-grid { min-width: 0; max-width: 100%; } + +.page-heading > *, +.flux-job-center-head > *, +.media-library-heading > * { min-width: 0; } + +/* FLUX archive, selection and paging */ +.flux-job-view-switch, +.media-view-switch { + display:inline-flex; + align-items:center; + gap:3px; + padding:3px; + border:1px solid var(--vd-line); + border-radius:10px; + background:var(--vd-surface-muted); +} +.flux-job-view-switch button, +.media-view-switch button { + min-height:30px; + padding:0 10px; + border:0; + border-radius:7px; + background:transparent; + color:var(--vd-ink-muted); + font-size:9px; + font-weight:760; + cursor:pointer; +} +.flux-job-view-switch button.active, +.media-view-switch button.active { + background:var(--vd-surface-raised); + color:var(--vd-ink); + box-shadow:0 1px 5px rgba(20,18,15,.08); +} +.flux-job-view-switch span { + display:inline-grid; + min-width:18px; + height:18px; + place-items:center; + margin-left:3px; + padding:0 5px; + border-radius:999px; + background:var(--vd-surface-muted); + font-size:8px; +} +.flux-job-bulkbar, +.media-batchbar { + min-height:46px; + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + margin-bottom:12px; + padding:8px 10px; + border:1px solid var(--vd-line); + border-radius:11px; + background:var(--vd-surface); +} +.flux-job-bulkbar > label, +.media-batchbar > label, +.media-check { + display:flex; + align-items:center; + gap:7px; + color:var(--vd-ink-secondary); + font-size:9px; + font-weight:680; +} +.flux-job-bulkbar input, +.media-batchbar input, +.media-check input, +.media-card-select input { accent-color:var(--vd-brand); } +.flux-job-bulkbar > div, +.media-batchbar > div { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } +.flux-job-pagination, +.media-pagination { + display:flex; + align-items:center; + justify-content:center; + gap:10px; + min-height:48px; + margin-top:12px; + color:var(--vd-ink-muted); + font-size:9px; +} +.flux-job-pagination .page-nav-btn, +.media-pagination .page-nav-btn { + width:34px; + height:34px; + border:1px solid var(--vd-line); + border-radius:8px; + background:var(--vd-surface-raised); + color:var(--vd-ink); + cursor:pointer; +} +.flux-job-pagination .page-nav-btn:disabled, +.media-pagination .page-nav-btn:disabled { opacity:.42; cursor:not-allowed; } +.flux-job-card-select { + display:inline-grid; + place-items:center; + flex:0 0 auto; + width:26px; + height:26px; + border-radius:7px; + background:var(--vd-surface-muted); +} +.flux-job-card-select input { width:15px; height:15px; accent-color:var(--vd-brand); } +.flux-job-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 15%,transparent); } + +/* Central media library */ +.media-library-page { padding-bottom:24px; } +.media-library-heading { align-items:center; } +.media-heading-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } +.media-summary { + display:grid; + grid-template-columns:repeat(5,minmax(0,1fr)); + gap:9px; + margin-bottom:12px; +} +.media-summary button { + min-width:0; + min-height:72px; + display:grid; + align-content:center; + gap:4px; + padding:11px 13px; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); + color:var(--vd-ink); + text-align:left; + cursor:pointer; +} +.media-summary button:hover, +.media-summary button.active { border-color:var(--vd-brand); background:var(--vd-brand-soft); } +.media-summary span { color:var(--vd-ink-muted); font-size:9px; font-weight:680; } +.media-summary strong { font-size:19px; line-height:1; } +.media-toolbar { + display:grid; + grid-template-columns:minmax(220px,1fr) repeat(2,minmax(130px,auto)) auto auto auto; + align-items:center; + gap:8px; + margin-bottom:10px; + padding:10px; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); +} +.media-toolbar select, +.media-search { + min-width:0; + min-height:38px; + border:1px solid var(--vd-line); + border-radius:9px; + background:var(--vd-surface); + color:var(--vd-ink); +} +.media-toolbar select { padding:0 30px 0 10px; font-size:9px; } +.media-search { display:grid; grid-template-columns:28px minmax(0,1fr); align-items:center; } +.media-search > span { display:grid; place-items:center; color:var(--vd-ink-muted); } +.media-search input { min-width:0; width:100%; height:36px; padding:0 9px 0 0; border:0; outline:0; background:transparent; color:var(--vd-ink); font-size:10px; } +.media-grid { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(min(220px,100%),1fr)); + gap:12px; +} +.media-card { + min-width:0; + overflow:hidden; + display:grid; + grid-template-rows:auto minmax(0,1fr) auto; + border:1px solid var(--vd-line); + border-radius:14px; + background:var(--vd-surface-raised); + transition:border-color .15s ease,transform .15s ease,box-shadow .15s ease; +} +.media-card:hover { transform:translateY(-1px); border-color:var(--vd-line-strong); box-shadow:var(--vd-shadow-sm); } +.media-card.is-selected { border-color:var(--vd-brand); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 15%,transparent); } +.media-card-image { position:relative; min-height:180px; aspect-ratio:4/3; overflow:hidden; background:var(--vd-surface-muted); } +.media-card-image img { width:100%; height:100%; display:block; object-fit:contain; } +.media-card-select { position:absolute; top:8px; left:8px; display:grid; place-items:center; width:28px; height:28px; border-radius:8px; background:rgba(18,18,16,.72); } +.media-card-select input { width:16px; height:16px; } +.media-card-favorite-mark { position:absolute; top:9px; right:10px; color:#ffd15c; font-size:18px; text-shadow:0 1px 5px rgba(0,0,0,.5); } +.media-card-preview { position:absolute; inset:auto 8px 8px auto; min-height:28px; padding:0 9px; border:1px solid rgba(255,255,255,.35); border-radius:7px; background:rgba(15,15,13,.72); color:#fff; font-size:8px; font-weight:760; cursor:pointer; } +.media-card-content { min-width:0; display:grid; align-content:start; gap:6px; padding:11px 12px; } +.media-card-content > strong { overflow:hidden; color:var(--vd-ink); font-size:10px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; } +.media-card-content > span, +.media-card-content > small { overflow:hidden; color:var(--vd-ink-muted); font-size:8px; line-height:1.4; text-overflow:ellipsis; white-space:nowrap; } +.media-card-sources { display:flex; align-items:center; gap:4px; flex-wrap:wrap; } +.media-source-chip { display:inline-flex; min-height:19px; align-items:center; padding:2px 6px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:7px; font-weight:780; } +.media-source-chip.is-flux { background:color-mix(in srgb,var(--vd-brand) 13%,var(--vd-surface)); color:var(--vd-brand); } +.media-source-chip.is-edited { background:color-mix(in srgb,var(--vd-info) 13%,var(--vd-surface)); color:var(--vd-info); } +.media-source-chip.is-ai { background:color-mix(in srgb,var(--vd-warning) 14%,var(--vd-surface)); color:var(--vd-warning); } +.media-source-chip.is-listing { background:color-mix(in srgb,var(--vd-success) 13%,var(--vd-surface)); color:var(--vd-success); } +.media-card-actions { display:flex; gap:5px; flex-wrap:wrap; padding:0 12px 12px; } +.media-card-actions .small-btn { flex:1 1 auto; min-width:0; } +.media-grid.is-list { grid-template-columns:1fr; } +.media-grid.is-list .media-card { grid-template-columns:128px minmax(0,1fr) auto; grid-template-rows:1fr; align-items:center; } +.media-grid.is-list .media-card-image { min-height:96px; height:100%; aspect-ratio:auto; } +.media-grid.is-list .media-card-actions { max-width:350px; justify-content:flex-end; padding:10px 12px 10px 0; } +.media-empty { grid-column:1/-1; min-height:260px; display:grid; place-items:center; align-content:center; gap:8px; padding:30px; border:1px dashed var(--vd-line); border-radius:14px; color:var(--vd-ink-muted); text-align:center; } +.media-empty strong { color:var(--vd-ink); font-size:14px; } +.media-empty span { font-size:9px; } + +.media-lightbox { position:fixed; inset:0; z-index:3000; display:grid; place-items:center; padding:22px; } +.media-lightbox.hidden { display:none; } +.media-lightbox-backdrop { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(10,10,9,.78); cursor:pointer; } +.media-lightbox-dialog { position:relative; width:min(1180px,100%); max-height:calc(100dvh - 44px); overflow:auto; border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-float); } +.media-lightbox-dialog > header, +.media-lightbox-dialog > footer { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:13px 16px; } +.media-lightbox-dialog > header { border-bottom:1px solid var(--vd-line); } +.media-lightbox-dialog > footer { justify-content:flex-end; flex-wrap:wrap; border-top:1px solid var(--vd-line); } +.media-lightbox-dialog h3 { margin:2px 0 0; overflow:hidden; max-width:min(700px,70vw); text-overflow:ellipsis; white-space:nowrap; } +.media-lightbox-body { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(250px,.55fr); min-height:480px; } +.media-lightbox-stage { min-width:0; display:grid; place-items:center; padding:18px; background:repeating-conic-gradient(#ddd 0 25%,#fff 0 50%) 50%/20px 20px; } +.media-lightbox-stage img { max-width:100%; max-height:70dvh; object-fit:contain; } +#media-lightbox-meta { min-width:0; overflow:auto; padding:18px; border-left:1px solid var(--vd-line); } +#media-lightbox-meta dl { display:grid; grid-template-columns:90px minmax(0,1fr); gap:8px 10px; margin:14px 0; font-size:9px; } +#media-lightbox-meta dt { color:var(--vd-ink-muted); } +#media-lightbox-meta dd { min-width:0; margin:0; overflow-wrap:anywhere; color:var(--vd-ink); } +.media-lightbox-prompt { display:grid; gap:6px; padding:10px; border-radius:10px; background:var(--vd-surface-muted); } +.media-lightbox-prompt strong { font-size:9px; } +.media-lightbox-prompt p { margin:0; color:var(--vd-ink-secondary); font-size:9px; line-height:1.5; } + +/* Background-removal sensitivity control */ +.pe-sensitivity-help { display:block; margin-top:4px; color:var(--vd-ink-muted); font-size:8px; line-height:1.4; } + +/* Real responsive listing cards instead of a viewport-breaking table. */ +@media (max-width: 1180px) { + .history-table { min-width:0; width:100%; --history-provider:0px; --history-location:0px; } + .history-table [data-column="provider"], + .history-table [data-column="location"] { display:none; } + .history-table-head, + .history-row { + grid-template-columns:34px minmax(230px,1.7fr) 96px 92px 112px minmax(128px,1fr) 40px; + } +} + +@media (max-width: 860px) { + .page-heading, + .media-library-heading { align-items:flex-start; flex-direction:column; } + .page-heading > :last-child, + .media-heading-actions { width:100%; } + .media-heading-actions .vd-button { flex:1; justify-content:center; } + + .history-filterbar { display:grid; grid-template-columns:1fr 1fr; align-items:stretch; } + .history-search-field { grid-column:1/-1; } + .history-custom-dates { grid-column:1/-1; grid-template-columns:1fr 1fr; } + .history-filterbar > .vd-button { min-height:42px; } + .history-batch-bar { flex-wrap:wrap; overflow:visible; } + .history-batch-actions { flex-wrap:wrap; } + .history-clear-selection { margin-left:0; } + .history-table-shell { overflow:visible; border:0; background:transparent; } + .history-table { min-width:0 !important; width:100%; overflow:visible; } + .history-table-head { display:none; } + .history-list { display:grid; gap:10px; } + .history-row { + min-height:0; + display:grid; + grid-template-columns:30px minmax(0,1fr) 38px; + gap:0; + align-items:start; + overflow:visible; + border:1px solid var(--vd-line); + border-radius:13px; + background:var(--vd-surface-raised); + } + .history-row:last-child { border-bottom:1px solid var(--vd-line); } + .history-cell { padding:8px 10px; } + .history-cell-select { grid-column:1; grid-row:1; padding:12px 0 0 12px; } + .history-cell-article { grid-column:2; grid-row:1; padding:10px 6px; } + .history-cell-menu { grid-column:3; grid-row:1; padding:9px 8px 0 0; } + .history-cell-platform, + .history-cell-provider, + .history-cell-price, + .history-cell-status, + .history-cell-location, + .history-cell-published { + display:grid !important; + grid-template-columns:92px minmax(0,1fr); + grid-column:1/-1; + align-items:center; + gap:8px; + min-height:38px; + margin:0 10px; + padding:7px 0; + border-top:1px solid var(--vd-line); + } + .history-cell-platform::before { content:"Plattform"; } + .history-cell-provider::before { content:"AI-Provider"; } + .history-cell-price::before { content:"Preis"; } + .history-cell-status::before { content:"Status"; } + .history-cell-location::before { content:"Lagerort"; } + .history-cell-published::before { content:"Aktualisiert"; } + .history-cell-platform::before, + .history-cell-provider::before, + .history-cell-price::before, + .history-cell-status::before, + .history-cell-location::before, + .history-cell-published::before { + color:var(--vd-ink-muted); + font-size:8px; + font-weight:720; + } + .history-cell-provider { align-items:center; } + .history-cell-provider > .history-provider-mark { display:none; } + .history-cell-provider > span:last-child, + .history-cell-price > *, + .history-cell-status > *, + .history-cell-location > *, + .history-cell-published > * { grid-column:2; } + .history-cell-provider::before, + .history-cell-price::before, + .history-cell-status::before, + .history-cell-location::before, + .history-cell-published::before { grid-column:1; grid-row:1 / span 2; } + .history-status-select { max-width:170px; } + .history-publish-badges { flex-wrap:wrap; } + .history-table-footer { align-items:stretch; flex-direction:column; padding:10px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-raised); } + .history-pagination-nav { justify-content:space-between; } + + .media-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } + .media-toolbar { grid-template-columns:1fr 1fr; } + .media-search { grid-column:1/-1; } + .media-view-switch { justify-self:end; } + .media-lightbox-body { grid-template-columns:1fr; } + #media-lightbox-meta { border-top:1px solid var(--vd-line); border-left:0; } + .media-grid.is-list .media-card { grid-template-columns:110px minmax(0,1fr); } + .media-grid.is-list .media-card-actions { grid-column:1/-1; max-width:none; padding:0 10px 10px; } + + .flux-job-center-head { align-items:stretch; } + .flux-job-center-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; } + .flux-job-center-actions > * { width:100%; } + .flux-job-view-switch { grid-column:1/-1; } + .flux-job-bulkbar, + .media-batchbar { align-items:flex-start; flex-direction:column; } +} + +@media (max-width: 620px) { + main { padding-inline:10px !important; } + .history-filterbar { grid-template-columns:1fr; } + .history-search-field, + .history-custom-dates { grid-column:auto; } + .history-custom-dates { grid-template-columns:1fr; } + .history-thumb { width:48px; height:44px; } + .history-cell-platform, + .history-cell-provider, + .history-cell-price, + .history-cell-status, + .history-cell-location, + .history-cell-published { grid-template-columns:78px minmax(0,1fr); } + .history-page-buttons { max-width:100%; overflow:auto; } + + .media-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } + .media-summary button:first-child { grid-column:1/-1; } + .media-toolbar { grid-template-columns:1fr; } + .media-search { grid-column:auto; } + .media-view-switch { justify-self:start; } + .media-grid { grid-template-columns:1fr 1fr; gap:8px; } + .media-card-image { min-height:130px; } + .media-card-content { padding:9px; } + .media-card-actions { padding:0 9px 9px; } + .media-card-actions .small-btn { flex:1 1 calc(50% - 4px); } + .media-grid.is-list { grid-template-columns:1fr; } + .media-grid.is-list .media-card { grid-template-columns:92px minmax(0,1fr); } + .media-lightbox { padding:8px; } + .media-lightbox-dialog { max-height:calc(100dvh - 16px); border-radius:12px; } + .media-lightbox-dialog > header { align-items:flex-start; } + .media-lightbox-dialog > footer .vd-button { flex:1 1 calc(50% - 6px); justify-content:center; } + .media-lightbox-stage { padding:8px; } + #media-lightbox-meta { padding:12px; } + + .flux-job-center { padding:12px; } + .flux-job-center-actions { grid-template-columns:1fr; } + .flux-job-view-switch { grid-column:auto; } + .flux-job-summary { grid-template-columns:1fr 1fr; } + .flux-job-card-toggle { gap:8px; padding:10px; } + .flux-job-card-toggle-meta { flex-direction:column; align-items:flex-end; } + .flux-job-card-body { padding:0 10px 10px; } + .flux-job-bulkbar > div, + .media-batchbar > div { width:100%; } + .flux-job-bulkbar .vd-button, + .media-batchbar .vd-button { flex:1 1 calc(50% - 4px); } +} + +@media (max-width: 430px) { + .media-grid { grid-template-columns:1fr; } + .media-summary { grid-template-columns:1fr 1fr; } + .flux-job-summary { grid-template-columns:1fr; } +} + +@media (max-width: 860px) { + .history-hide-platform .history-cell-platform, + .history-hide-provider .history-cell-provider, + .history-hide-price .history-cell-price, + .history-hide-status .history-cell-status, + .history-hide-location .history-cell-location, + .history-hide-published .history-cell-published { display:none !important; } +} + +/* ========================================================================== + Vendoo 1.25.0 — responsive editor, polished gallery, prompt lab & modals + ========================================================================== */ +body.modal-open { overflow:hidden; } + +/* Listing editor: react to the available content width, not only viewport. */ +.listings-page .history-detail { + width:100%; + max-width:100%; + min-width:0; + overflow-x:clip; + container-type:inline-size; + container-name:listing-editor; +} +.listing-editor-shell, +.listing-editor-grid, +.listing-content-editor, +.listing-media-editor, +.listing-context-panel, +.listing-editor-section, +.listing-description-workbench, +.detail-platform-workbench, +.detail-description-pane, +.detail-html-switch-stage, +.detail-html-switch-stage > *, +.detail-html-preview, +#detail-description-editor, +#detail-html-source { min-width:0; max-width:100%; box-sizing:border-box; } +.listing-editor-grid { + width:100%; + grid-template-columns:minmax(190px,.78fr) minmax(0,1.65fr) minmax(170px,.62fr); +} +.listing-editor-header > div:first-child { min-width:0; } +.listing-editor-header h2, +.listing-editor-header p, +.listing-section-heading h3, +.listing-context-card strong { overflow-wrap:anywhere; word-break:break-word; } +.listing-form-grid > label, +.detail-photo-actions > *, +.listing-editor-header-actions > * { min-width:0; } +.detail-description-pane .ql-toolbar.ql-snow { display:flex; flex-wrap:wrap; gap:2px; overflow:visible; } +.detail-description-pane .ql-toolbar .ql-formats { margin-right:5px; } +.detail-platform-workbench iframe, +.detail-html-preview { width:100%; } + +@container listing-editor (max-width: 1080px) { + .listing-editor-grid { grid-template-columns:minmax(210px,260px) minmax(0,1fr); } + .listing-context-panel { grid-column:1/-1; position:static; grid-template-columns:repeat(4,minmax(0,1fr)); } + .listing-media-editor { top:64px; } +} +@container listing-editor (max-width: 760px) { + .listing-editor-shell { padding-inline:10px; } + .listing-editor-grid { grid-template-columns:minmax(0,1fr); } + .listing-media-editor,.listing-context-panel { position:static; } + .listing-context-panel { grid-template-columns:repeat(2,minmax(0,1fr)); } + .listing-form-grid.three { grid-template-columns:repeat(2,minmax(0,1fr)); } + .detail-photo-stage { aspect-ratio:4/3; max-height:460px; } +} +@container listing-editor (max-width: 500px) { + .listing-editor-header { flex-direction:column; } + .listing-editor-header-actions { width:100%; display:grid; grid-template-columns:1fr 1fr; } + .listing-form-grid.three,.listing-form-grid.two,.listing-context-panel { grid-template-columns:1fr; } + .detail-photo-actions { grid-template-columns:1fr 1fr; } + .listing-section-heading { align-items:flex-start; } +} +@media(max-width:620px) { + .listings-page .history-detail { border-radius:10px; } + .listing-editor-shell { padding-inline:8px; } + .listing-editor-header-actions { display:grid; grid-template-columns:1fr 1fr; } + .detail-photo-actions { grid-template-columns:1fr 1fr; } +} +@media(max-width:390px) { + .listing-editor-header-actions,.detail-photo-actions { grid-template-columns:1fr; } +} + +/* Gallery cards: clean overlays, aligned controls and stable action grid. */ +.media-grid { align-items:stretch; } +.media-card { isolation:isolate; } +.media-card-image { min-height:190px; } +.media-card-image::after { + content:""; position:absolute; inset:auto 0 0; height:64px; pointer-events:none; + background:linear-gradient(transparent,rgba(16,16,14,.28)); +} +.media-card-select { + top:10px; left:10px; width:32px; height:32px; padding:0; z-index:4; + border:1px solid rgba(255,255,255,.55); border-radius:10px; + background:rgba(20,20,18,.68); box-shadow:0 5px 18px rgba(0,0,0,.18); backdrop-filter:blur(8px); + cursor:pointer; +} +.media-card-select { display:grid; place-items:center; } +.media-card-select input { position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; } +.media-card-select span { + width:18px; height:18px; display:grid; place-items:center; pointer-events:none; + border:2px solid rgba(255,255,255,.94); border-radius:5px; background:rgba(255,255,255,.08); + transition:background .15s ease,border-color .15s ease,transform .15s ease; +} +.media-card-select input:focus-visible + span { outline:2px solid #fff; outline-offset:2px; } +.media-card-select input:checked + span { border-color:#fff; background:var(--vd-brand); transform:scale(1.03); } +.media-card-select input:checked + span::after { + content:""; width:8px; height:4px; margin-top:-2px; + border-left:2px solid #fff; border-bottom:2px solid #fff; transform:rotate(-45deg); +} +.media-card-favorite-toggle { + position:absolute; top:10px; right:10px; z-index:4; width:32px; height:32px; display:grid; place-items:center; + padding:0; border:1px solid rgba(255,255,255,.55); border-radius:10px; + background:rgba(20,20,18,.68); color:#fff; font-size:17px; cursor:pointer; backdrop-filter:blur(8px); +} +.media-card-favorite-toggle.is-active { color:#ffd15c; background:rgba(20,20,18,.82); } +.media-card-preview { + inset:auto 10px 10px 10px; z-index:3; min-height:34px; display:flex; align-items:center; justify-content:center; gap:6px; + border:1px solid rgba(255,255,255,.5); border-radius:10px; background:rgba(18,18,16,.78); + box-shadow:0 8px 24px rgba(0,0,0,.16); backdrop-filter:blur(9px); font-size:9px; +} +.media-card-preview span { font-size:13px; line-height:1; } +.media-card-content { min-height:116px; padding:12px 14px 10px; } +.media-card-content > strong { + display:-webkit-box; min-height:2.7em; overflow:hidden; white-space:normal; -webkit-line-clamp:2; -webkit-box-orient:vertical; + font-size:10px; line-height:1.35; +} +.media-card-content > span,.media-card-content > small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.media-card-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; padding:0 14px 14px; } +.media-card-actions .small-btn { + width:100%; min-height:34px; display:flex; align-items:center; justify-content:center; margin:0; + border-radius:9px; text-align:center; text-decoration:none; +} +.media-grid.is-list .media-card-actions { display:grid; grid-template-columns:repeat(2,minmax(120px,1fr)); width:min(360px,100%); } +@media(max-width:620px) { + .media-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } + .media-card-image { min-height:150px; } + .media-card-content { min-height:112px; } + .media-card-actions { padding-inline:10px; } +} +@media(max-width:470px) { + .media-grid { grid-template-columns:1fr; } + .media-card-image { min-height:220px; } +} + +/* Publish: calm refresh state and restored Smart Copy popup. */ +#publish-refresh[aria-busy="true"],#publish-status-refresh[aria-busy="true"] { opacity:.72; cursor:wait; transform:none !important; animation:none !important; } +.smart-copy-launcher { + min-height:360px; display:grid; grid-template-columns:56px minmax(0,1fr) auto; align-items:center; gap:18px; + padding:28px; border:1px dashed var(--vd-line-strong); border-radius:14px; background:var(--vd-surface-muted); +} +.smart-copy-launcher-icon { width:52px; height:52px; display:grid; place-items:center; border-radius:14px; background:var(--vd-brand); color:#fff; font-size:25px; } +.smart-copy-launcher h3 { margin:4px 0 7px; } +.smart-copy-launcher p { max-width:650px; color:var(--vd-ink-muted); line-height:1.55; } +.smart-copy-launcher-score { display:grid; min-width:105px; gap:2px; padding:12px; border:1px solid var(--vd-line); border-radius:11px; background:var(--vd-surface-raised); text-align:center; } +.smart-copy-launcher-score strong { font-size:20px; color:var(--vd-brand); } +.smart-copy-launcher-score span { font-size:8px; color:var(--vd-ink-muted); } +.smart-copy-launcher > .vd-button { grid-column:2/-1; justify-self:start; } +.smart-copy-modal,.flux-prompt-lab { position:fixed; inset:0; z-index:4200; display:grid; place-items:center; padding:20px; } +.smart-copy-modal.hidden,.flux-prompt-lab.hidden { display:none; } +.smart-copy-modal-backdrop,.flux-prompt-lab-backdrop { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(9,9,8,.76); backdrop-filter:blur(4px); } +.smart-copy-modal-dialog,.flux-prompt-lab-dialog { + position:relative; width:min(1180px,100%); max-height:calc(100dvh - 40px); overflow:auto; + border:1px solid var(--vd-line); border-radius:18px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-float); +} +.smart-copy-modal-dialog > header,.flux-prompt-lab-dialog > header { + position:sticky; top:0; z-index:5; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; + padding:16px 18px; border-bottom:1px solid var(--vd-line); background:color-mix(in srgb,var(--vd-surface-raised) 94%,transparent); backdrop-filter:blur(12px); +} +.smart-copy-modal-dialog > header h2,.flux-prompt-lab-dialog > header h2 { margin:2px 0 4px; } +.smart-copy-modal-dialog > header p:last-child,.flux-prompt-lab-dialog > header p:last-child { margin:0; color:var(--vd-ink-muted); font-size:9px; } +.smart-copy-modal-content { padding:14px 18px 18px; } +.smart-copy-modal-content .publish-workspace-header { border:1px solid var(--vd-line); border-radius:12px; padding:11px; margin-bottom:12px; } +.smart-copy-modal-content .publish-smart-grid { grid-template-columns:minmax(0,1.5fr) minmax(250px,.62fr); } +@media(max-width:850px) { + .smart-copy-launcher { grid-template-columns:48px minmax(0,1fr); } + .smart-copy-launcher-score { grid-column:1/-1; width:100%; } + .smart-copy-launcher > .vd-button { grid-column:1/-1; width:100%; justify-content:center; } + .smart-copy-modal-content .publish-smart-grid { grid-template-columns:1fr; } +} +@media(max-width:560px) { + .smart-copy-modal,.flux-prompt-lab { padding:7px; } + .smart-copy-modal-dialog,.flux-prompt-lab-dialog { max-height:calc(100dvh - 14px); border-radius:12px; } + .smart-copy-modal-content { padding:9px; } + .smart-copy-launcher { min-height:0; padding:18px; grid-template-columns:1fr; text-align:center; } + .smart-copy-launcher-icon { margin:auto; } +} + +/* FLUX Prompt Lab */ +.flux-prompt-lab-dialog { width:min(920px,100%); } +.flux-prompt-lab-body { display:grid; gap:13px; padding:16px 18px; } +.flux-prompt-lab-provider { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface-muted); } +.flux-prompt-lab-provider span { color:var(--vd-ink-muted); font-size:8px; } +.flux-prompt-lab-provider strong { color:var(--vd-brand); font-size:9px; overflow-wrap:anywhere; text-align:right; } +.flux-prompt-lab-field { display:grid; gap:6px; color:var(--vd-ink-secondary); font-size:8px; font-weight:720; } +.flux-prompt-lab-field textarea { width:100%; min-height:120px; resize:vertical; padding:11px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface); color:var(--vd-ink); font:inherit; font-size:10px; line-height:1.55; } +.flux-prompt-lab-options { display:grid; grid-template-columns:minmax(220px,1fr) 1fr 1fr; gap:10px; } +.flux-prompt-lab-options > label { min-width:0; } +.flux-prompt-lab-options select { width:100%; min-height:40px; margin-top:5px; } +.flux-prompt-lab-check { display:flex; align-items:flex-start; gap:8px; padding:10px; border:1px solid var(--vd-line); border-radius:10px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:8px; line-height:1.45; } +.flux-prompt-lab-check input { width:17px; height:17px; flex:0 0 auto; accent-color:var(--vd-brand); } +.flux-prompt-lab-actions { display:flex; gap:9px; flex-wrap:wrap; } +.flux-prompt-lab-status { min-height:34px; display:flex; align-items:center; padding:8px 10px; border-radius:9px; background:var(--vd-brand-soft); color:var(--vd-brand); font-size:8px; } +.flux-prompt-lab-notes { min-height:18px; margin:0; color:var(--vd-ink-muted); font-size:8px; line-height:1.45; } +.flux-prompt-lab-dialog > footer { position:sticky; bottom:0; display:flex; justify-content:flex-end; gap:9px; padding:13px 18px; border-top:1px solid var(--vd-line); background:var(--vd-surface-raised); } +.flux-optimize-button { display:inline-flex; align-items:center; gap:6px; } +.flux-optimize-button > span { color:var(--vd-brand); font-size:14px; } +@media(max-width:760px) { .flux-prompt-lab-options { grid-template-columns:1fr; } } + +/* FLUX library and job pagination: compact, bounded page sizes. */ +.flux-library-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex-wrap:wrap; } +.flux-library-actions select { min-height:34px; } +.flux-history-pagination { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:13px; } +.flux-history-pagination span { color:var(--vd-ink-muted); font-size:8px; } +.flux-studio-history-section { min-width:0; } +@media(max-width:620px) { + .flux-studio-history-head { align-items:flex-start; flex-direction:column; gap:10px; } + .flux-library-actions { width:100%; justify-content:flex-start; } + .flux-library-actions > * { flex:1 1 auto; } +} + +/* Broad width hardening for all primary workspaces. */ +.publish-center-page,.media-library-page,.flux-studio-page,.settings-workspace,.generator-page,.listings-page, +.publish-center-grid,.publish-workspace-panel,.publish-workspace,.media-toolbar,.media-batchbar,.flux-job-center, +.flux-job-center-head,.flux-job-center-body,.flux-studio-layout { min-width:0; max-width:100%; box-sizing:border-box; } +img,video,canvas,iframe { max-width:100%; } +button,input,select,textarea { max-width:100%; } + +/* ===================================================================== + Vendoo 1.25.1 — fully adaptive media gallery + ===================================================================== */ +.media-library-page { + min-width:0; + container-type:inline-size; + container-name:media-library; +} + +.media-grid { + width:100%; + min-width:0; + display:grid; + grid-template-columns:repeat(auto-fit,minmax(min(100%,clamp(214px,19vw,286px)),1fr)); + gap:clamp(12px,1.35vw,20px); + align-items:start; +} + +.media-card { + container-type:inline-size; + container-name:media-card; + min-width:0; + overflow:hidden; + display:grid; + grid-template-rows:auto auto auto; + border:1px solid var(--vd-line); + border-radius:clamp(12px,5cqi,18px); + background:var(--vd-surface-raised); + box-shadow:0 2px 10px rgba(25,22,18,.055); + transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease; +} +.media-card:hover { + transform:translateY(-2px); + border-color:var(--vd-line-strong); + box-shadow:0 10px 28px rgba(25,22,18,.11); +} +.media-card.is-selected { + border-color:var(--vd-brand); + box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 17%,transparent),0 10px 28px rgba(25,22,18,.1); +} + +.media-card-image { + position:relative; + min-width:0; + min-height:0; + aspect-ratio:4/3; + overflow:hidden; + background:var(--vd-surface-muted); +} +.media-card-image::after { + content:none; +} +.media-card-open { + position:absolute; + inset:0; + z-index:1; + width:100%; + height:100%; + display:block; + margin:0; + padding:0; + overflow:hidden; + border:0; + border-radius:0; + background:transparent; + cursor:zoom-in; +} +.media-card-open img { + width:100%; + height:100%; + display:block; + object-fit:contain; + transition:transform .22s ease,filter .22s ease; +} +.media-card-open::before { + content:"⌕"; + position:absolute; + inset:50% auto auto 50%; + z-index:2; + width:clamp(34px,15cqi,48px); + height:clamp(34px,15cqi,48px); + display:grid; + place-items:center; + border:1px solid rgba(255,255,255,.68); + border-radius:999px; + background:rgba(20,20,18,.7); + color:#fff; + font-size:clamp(17px,7cqi,23px); + opacity:0; + transform:translate(-50%,-44%) scale(.92); + box-shadow:0 8px 24px rgba(0,0,0,.2); + backdrop-filter:blur(8px); + pointer-events:none; + transition:opacity .16s ease,transform .16s ease; +} +.media-card-open:hover img, +.media-card-open:focus-visible img { transform:scale(1.018); filter:brightness(.94); } +.media-card-open:hover::before, +.media-card-open:focus-visible::before { opacity:1; transform:translate(-50%,-50%) scale(1); } +.media-card-open:focus-visible { outline:3px solid var(--vd-brand); outline-offset:-3px; } +.media-card.is-selected .media-card-image { box-shadow:inset 0 0 0 3px var(--vd-brand); } + +.media-card-select, +.media-card-favorite-toggle { + position:absolute; + top:clamp(8px,4cqi,12px); + z-index:5; + width:clamp(30px,12cqi,38px); + height:clamp(30px,12cqi,38px); + display:grid; + place-items:center; + margin:0; + padding:0; + border:1px solid rgba(255,255,255,.66); + border-radius:clamp(9px,4cqi,12px); + background:rgba(22,22,20,.7); + box-shadow:0 6px 18px rgba(0,0,0,.18); + backdrop-filter:blur(9px); +} +.media-card-select { left:clamp(8px,4cqi,12px); cursor:pointer; } +.media-card-favorite-toggle { + right:clamp(8px,4cqi,12px); + color:#fff; + font-size:clamp(16px,7cqi,21px); + line-height:1; + cursor:pointer; + transition:transform .15s ease,background .15s ease,color .15s ease; +} +.media-card-favorite-toggle:hover { transform:scale(1.06); background:rgba(22,22,20,.86); } +.media-card-favorite-toggle:focus-visible { outline:2px solid #fff; outline-offset:2px; } +.media-card-favorite-toggle.is-active { color:#ffd15c; background:rgba(22,22,20,.86); } +.media-card-select input { + position:absolute; + inset:0; + width:100%; + height:100%; + margin:0; + opacity:0; + cursor:pointer; +} +.media-card-select span { + width:clamp(16px,6.5cqi,21px); + height:clamp(16px,6.5cqi,21px); + display:grid; + place-items:center; + border:2px solid rgba(255,255,255,.96); + border-radius:clamp(4px,2cqi,6px); + background:rgba(255,255,255,.08); + pointer-events:none; + transition:background .15s ease,border-color .15s ease,transform .15s ease; +} +.media-card-select input:focus-visible + span { outline:2px solid #fff; outline-offset:3px; } +.media-card-select input:checked + span { border-color:#fff; background:var(--vd-brand); transform:scale(1.04); } +.media-card-select input:checked + span::after { + content:""; + width:45%; + height:24%; + margin-top:-9%; + border-left:2px solid #fff; + border-bottom:2px solid #fff; + transform:rotate(-45deg); +} + +.media-card-content { + min-width:0; + min-height:0; + display:grid; + align-content:start; + gap:clamp(5px,2.4cqi,8px); + padding:clamp(11px,5cqi,16px) clamp(12px,5.5cqi,17px) clamp(9px,4cqi,13px); +} +.media-card-content > strong { + min-width:0; + min-height:2.7em; + display:-webkit-box; + overflow:hidden; + color:var(--vd-ink); + font-size:clamp(12px,5.2cqi,15px); + font-weight:760; + line-height:1.35; + white-space:normal; + -webkit-line-clamp:2; + -webkit-box-orient:vertical; +} +.media-card-content > span, +.media-card-content > small { + min-width:0; + overflow:hidden; + color:var(--vd-ink-muted); + font-size:clamp(10px,4cqi,12px); + line-height:1.4; + text-overflow:ellipsis; + white-space:nowrap; +} +.media-card-sources { gap:clamp(4px,2cqi,7px); } +.media-source-chip { + min-height:clamp(20px,8cqi,25px); + padding:2px clamp(7px,3cqi,10px); + font-size:clamp(9px,3.7cqi,11px); +} + +.media-card-actions { + min-width:0; + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:clamp(5px,2.2cqi,8px); + padding:0 clamp(10px,5cqi,16px) clamp(11px,5cqi,16px); +} +.media-action { + min-width:0; + min-height:clamp(40px,16cqi,48px); + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:2px; + margin:0; + padding:5px 4px; + overflow:hidden; + border:1px solid var(--vd-line); + border-radius:clamp(8px,3.7cqi,11px); + background:var(--vd-surface); + color:var(--vd-ink-secondary); + font:inherit; + text-align:center; + text-decoration:none; + cursor:pointer; + transition:border-color .15s ease,background .15s ease,color .15s ease,transform .15s ease; +} +.media-action:hover { + border-color:var(--vd-line-strong); + background:var(--vd-surface-muted); + color:var(--vd-ink); + transform:translateY(-1px); +} +.media-action:focus-visible { outline:2px solid var(--vd-brand); outline-offset:2px; } +.media-action.danger:hover { border-color:color-mix(in srgb,var(--vd-danger) 50%,var(--vd-line)); color:var(--vd-danger); } +.media-action-icon { font-size:clamp(16px,6cqi,20px); line-height:1; } +.media-action-label { + max-width:100%; + overflow:hidden; + font-size:clamp(8px,3.5cqi,10px); + font-weight:700; + line-height:1.1; + text-overflow:ellipsis; + white-space:nowrap; +} + +.media-pagination { + width:100%; + min-width:0; + min-height:48px; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin:0; + padding:8px 2px; + color:var(--vd-ink-muted); + font-size:11px; +} +.media-pagination-top { margin:4px 0 12px; border-bottom:1px solid var(--vd-line); } +.media-pagination-bottom { margin:12px 0 0; border-top:1px solid var(--vd-line); } +.media-pagination-summary { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.media-pagination-controls { display:flex; align-items:center; justify-content:flex-end; gap:8px; } +.media-page-jump { display:flex; align-items:center; gap:6px; white-space:nowrap; } +.media-page-jump select { + min-width:64px; + height:34px; + padding:0 26px 0 10px; + border:1px solid var(--vd-line); + border-radius:8px; + background:var(--vd-surface-raised); + color:var(--vd-ink); +} +.media-pagination .page-nav-btn { flex:0 0 34px; } + +.media-grid.is-list { grid-template-columns:1fr; gap:10px; } +.media-grid.is-list .media-card { + grid-template-columns:clamp(150px,22vw,230px) minmax(0,1fr) minmax(220px,auto); + grid-template-rows:auto; + align-items:center; + border-radius:14px; +} +.media-grid.is-list .media-card-image { width:100%; height:100%; min-height:118px; aspect-ratio:4/3; } +.media-grid.is-list .media-card-content { padding:14px 16px; } +.media-grid.is-list .media-card-actions { width:min(330px,100%); padding:12px 14px 12px 0; } + +@container media-card (max-width: 235px) { + .media-card-content > small { display:none; } + .media-card-actions { gap:5px; padding-inline:9px; } + .media-action { min-height:40px; padding:4px; } + .media-action-label { display:none; } + .media-action-icon { font-size:18px; } +} + +@container media-card (min-width: 340px) { + .media-action { flex-direction:row; gap:6px; } + .media-action-label { font-size:10px; } +} + +@container media-library (max-width: 760px) { + .media-toolbar { grid-template-columns:1fr 1fr; } + .media-search { grid-column:1/-1; } + .media-view-switch { justify-self:end; } + .media-batchbar { align-items:flex-start; } + .media-grid.is-list .media-card { + grid-template-columns:clamp(120px,32vw,180px) minmax(0,1fr); + } + .media-grid.is-list .media-card-actions { + grid-column:1/-1; + width:100%; + padding:0 12px 12px; + } +} + +@container media-library (max-width: 520px) { + .media-toolbar { grid-template-columns:1fr; } + .media-search { grid-column:auto; } + .media-view-switch { justify-self:stretch; } + .media-view-switch button { flex:1; } + .media-batchbar { flex-direction:column; } + .media-batchbar > div { width:100%; } + .media-batchbar .vd-button { flex:1; } + .media-pagination { align-items:flex-start; flex-direction:column; gap:7px; } + .media-pagination-controls { width:100%; justify-content:space-between; } + .media-grid.is-list .media-card { grid-template-columns:110px minmax(0,1fr); } + .media-grid.is-list .media-card-content { padding:10px 11px; } +} + +@media (hover:none) { + .media-card-open::before { display:none; } + .media-card:hover { transform:none; } +} + +/* 1.25.1 icon refinement */ +.media-card-open::before { content:none; } +.media-open-indicator { + position:absolute; + inset:50% auto auto 50%; + z-index:2; + width:clamp(36px,15cqi,48px); + height:clamp(36px,15cqi,48px); + display:grid; + place-items:center; + border:1px solid rgba(255,255,255,.7); + border-radius:999px; + background:rgba(20,20,18,.72); + color:#fff; + opacity:0; + transform:translate(-50%,-44%) scale(.92); + box-shadow:0 8px 24px rgba(0,0,0,.2); + backdrop-filter:blur(8px); + pointer-events:none; + transition:opacity .16s ease,transform .16s ease; +} +.media-open-indicator .media-action-icon { width:20px; height:20px; } +.media-card-open:hover .media-open-indicator, +.media-card-open:focus-visible .media-open-indicator { opacity:1; transform:translate(-50%,-50%) scale(1); } +.media-action-icon { + width:clamp(17px,6cqi,20px); + height:clamp(17px,6cqi,20px); + flex:0 0 auto; + fill:none; + stroke:currentColor; + stroke-width:1.8; + stroke-linecap:round; + stroke-linejoin:round; +} +@media (hover:none) { .media-open-indicator { display:none; } } + +/* Vendoo 1.26.0 — Artikel, Extensions, adaptive gallery controls and zoom preview */ +.vd-icon { display:block; flex:0 0 auto; } +.vd-icon-host { display:inline-grid; place-items:center; } +.vd-button .vd-icon { margin-right:6px; } +.icon-button .vd-icon { margin:auto; } + +/* Compact media overlays and action dock */ +.media-card-select, +.media-card-favorite-toggle { + top:clamp(6px,3cqi,9px); + width:clamp(25px,10cqi,30px); + height:clamp(25px,10cqi,30px); + min-width:0; + padding:0; + border-radius:9px; + background:rgba(20,20,18,.72); + border:1px solid rgba(255,255,255,.68); + box-shadow:0 4px 14px rgba(0,0,0,.18); + backdrop-filter:blur(7px); +} +.media-card-select { left:clamp(6px,3cqi,9px); } +.media-card-favorite-toggle { right:clamp(6px,3cqi,9px); font-size:0; } +.media-card-favorite-toggle .vd-icon { width:clamp(14px,5.5cqi,17px); height:clamp(14px,5.5cqi,17px); } +.media-card-select input + span { + width:clamp(15px,6cqi,18px); + height:clamp(15px,6cqi,18px); + border-radius:5px; + display:grid; + place-items:center; + color:transparent; +} +.media-card-select input:checked + span { color:#fff; } +.media-card-select input:checked + span::after { content:none; } +.media-card-actions { gap:5px; padding:8px; } +.media-action { + min-width:0; + min-height:34px; + padding:7px; + border-radius:9px; + justify-content:center; +} +.media-action .media-action-label { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; } +.media-action-icon { width:17px; height:17px; } +.media-open-indicator { width:38px; height:38px; } +.media-open-indicator .media-action-icon { width:18px; height:18px; } +@container media-card (min-width: 390px) { + .media-action { gap:6px; padding-inline:10px; } + .media-action .media-action-label { position:static; width:auto; height:auto; overflow:visible; clip:auto; white-space:normal; } +} + +/* Full zoomable preview */ +.media-lightbox-dialog { width:min(1440px,calc(100vw - 32px)); height:min(940px,calc(100dvh - 32px)); max-height:none; display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; overflow:hidden; } +.media-lightbox-dialog > header { min-height:58px; } +.media-lightbox-close { border-radius:999px; } +.media-lightbox-toolbar { display:flex; align-items:center; gap:8px; padding:8px 14px; border-bottom:1px solid var(--vd-line); background:var(--vd-surface-muted); } +.media-lightbox-toolbar input[type="range"] { width:min(260px,32vw); } +.media-lightbox-toolbar output { min-width:58px; color:var(--vd-ink-muted); font-size:10px; text-align:right; } +.media-lightbox-body { min-height:0; height:100%; grid-template-columns:minmax(0,1fr) minmax(245px,320px); } +.media-lightbox-stage { position:relative; display:block; min-height:0; padding:14px; overflow:auto; cursor:grab; overscroll-behavior:contain; user-select:none; } +.media-lightbox-stage.is-dragging { cursor:grabbing; } +.media-lightbox-canvas { position:relative; min-width:1px; min-height:1px; margin:auto; box-shadow:0 12px 36px rgba(0,0,0,.25); } +.media-lightbox-stage img { display:block; max-width:none; max-height:none; object-fit:fill; pointer-events:none; } +#media-lightbox-meta { min-height:0; } +.media-lightbox-dialog > footer .vd-button { display:inline-flex; align-items:center; } +@media (max-width: 820px) { + .media-lightbox-dialog { width:calc(100vw - 12px); height:calc(100dvh - 12px); } + .media-lightbox-body { grid-template-columns:1fr; grid-template-rows:minmax(300px,1fr) auto; } + #media-lightbox-meta { max-height:190px; border-left:0; border-top:1px solid var(--vd-line); } + .media-lightbox-toolbar { flex-wrap:wrap; } + .media-lightbox-toolbar input[type="range"] { flex:1; width:auto; min-width:120px; } +} + +/* Publish center: full information in the middle and resilient responsive layout */ +.smart-copy-center { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(250px,.55fr); gap:16px; padding:18px; } +.smart-copy-center-main, +.smart-copy-center-side { min-width:0; display:grid; align-content:start; gap:14px; } +.smart-copy-center-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; } +.smart-copy-center-heading h3 { margin:4px 0 0; overflow-wrap:anywhere; } +.smart-copy-center-description { max-height:280px; overflow:auto; padding:14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); line-height:1.58; overflow-wrap:anywhere; } +.smart-copy-center-tags { display:flex; flex-wrap:wrap; gap:6px; } +.smart-copy-center-tags span { padding:5px 8px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:9px; } +.smart-copy-center-meta { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.smart-copy-center-meta > div { min-width:0; padding:10px; border:1px solid var(--vd-line); border-radius:10px; } +.smart-copy-center-meta span { display:block; color:var(--vd-ink-muted); font-size:8px; } +.smart-copy-center-meta strong { display:block; margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; } +.smart-copy-center-side > p { margin:0; color:var(--vd-ink-muted); line-height:1.5; } +@media (max-width: 1050px) { + .smart-copy-center { grid-template-columns:1fr; } + .smart-copy-center-side { grid-template-columns:minmax(120px,.35fr) minmax(0,1fr); } + .smart-copy-center-side > p { grid-column:1/-1; } +} +@media (max-width: 680px) { + .smart-copy-center { padding:12px; } + .smart-copy-center-heading { align-items:stretch; flex-direction:column; } + .smart-copy-center-heading .vd-button { width:100%; justify-content:center; } + .smart-copy-center-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } + .smart-copy-center-side { grid-template-columns:1fr; } +} + +/* Extension center */ +.extensions-page { width:100%; min-width:0; } +.extensions-heading { align-items:flex-start; } +.extensions-active-browser { display:grid; gap:4px; min-width:210px; padding:12px 14px; border:1px solid var(--vd-line); border-radius:12px; background:var(--vd-surface-raised); } +.extensions-active-browser span { color:var(--vd-ink-muted); font-size:8px; text-transform:uppercase; letter-spacing:.08em; } +.extensions-active-browser strong { display:flex; align-items:center; gap:8px; } +.extensions-notice { display:flex; gap:12px; align-items:flex-start; margin:0 0 16px; padding:14px 16px; border:1px solid color-mix(in srgb,var(--vd-brand) 24%,var(--vd-line)); border-radius:14px; background:color-mix(in srgb,var(--vd-brand) 6%,var(--vd-surface-raised)); } +.extensions-notice > span { width:34px; height:34px; display:grid; place-items:center; border-radius:10px; background:var(--vd-brand); color:#fff; } +.extensions-notice p { margin:4px 0 0; color:var(--vd-ink-muted); } +.extensions-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,285px),1fr)); gap:14px; } +.extension-card { min-width:0; display:grid; align-content:start; gap:13px; padding:16px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); box-shadow:var(--vd-shadow-card); } +.extension-card.is-active { border-color:color-mix(in srgb,var(--vd-brand) 55%,var(--vd-line)); box-shadow:0 0 0 2px color-mix(in srgb,var(--vd-brand) 12%,transparent),var(--vd-shadow-card); } +.extension-card header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:10px; align-items:center; } +.extension-browser-icon { width:44px; height:44px; display:grid; place-items:center; border-radius:13px; background:var(--vd-surface-muted); color:var(--vd-brand); } +.extension-card header span { color:var(--vd-brand); font-size:8px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; } +.extension-card h3 { margin:2px 0 0; } +.extension-card header b { align-self:start; padding:4px 7px; border-radius:999px; background:var(--vd-surface-muted); font-size:8px; } +.extension-card > p, +.extension-card > small { margin:0; color:var(--vd-ink-muted); line-height:1.5; } +.extension-path { min-width:0; display:grid; gap:5px; padding:10px; border-radius:10px; background:var(--vd-surface-muted); } +.extension-path span { font-size:8px; color:var(--vd-ink-muted); } +.extension-path code { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; } +.extension-card ol { margin:0; padding-left:20px; color:var(--vd-ink-secondary); line-height:1.7; } +.extension-card-actions { display:flex; gap:7px; align-items:center; } +.extension-card-actions .vd-button { flex:1; justify-content:center; } +.extension-card-actions .icon-button { flex:0 0 auto; } +.extensions-help-card { display:grid; gap:12px; margin-top:16px; padding:16px; border:1px solid var(--vd-line); border-radius:16px; background:var(--vd-surface-raised); } +.extensions-help-card > div { display:flex; gap:12px; } +.extensions-help-card > div > span { width:36px; height:36px; display:grid; place-items:center; border-radius:11px; background:var(--vd-surface-muted); } +.extensions-help-card p { margin:4px 0 0; color:var(--vd-ink-muted); } +.extensions-help-card ol { margin:0; padding-left:22px; line-height:1.7; } +@media (max-width: 620px) { + .extensions-heading { display:grid; } + .extensions-active-browser { width:100%; min-width:0; } + .extension-card-actions { flex-wrap:wrap; } +} + +/* Fee transparency */ +.fee-platform { display:flex; align-items:center; gap:7px; } +.fee-platform span { padding:3px 6px; border-radius:999px; background:var(--vd-surface-muted); color:var(--vd-ink-muted); font-size:8px; } +.fee-note { margin-top:6px; max-width:700px; color:var(--vd-ink-muted); font-size:9px; line-height:1.45; } +.fee-card.is-free .fee-net-amount { color:var(--vd-success,#198754); } +.fee-card.is-free .fee-deducted { color:var(--vd-success,#198754); } + +/* Vendoo 1.26.0 — article navigation, extension center, zoom preview and fee transparency */ +.extensions-heading-actions { display:grid; gap:10px; justify-items:stretch; min-width:min(100%, 260px); } +.extensions-heading-actions .vd-button { justify-content:center; width:100%; } +.fee-heading { align-items:flex-start; margin-bottom:18px; } +.fee-heading > div { max-width:780px; } +.fee-verified-badge { display:inline-flex; align-items:center; min-height:34px; padding:0 12px; border:1px solid var(--vd-line); border-radius:999px; background:var(--vd-surface-raised); color:var(--vd-ink-muted); font-size:12px; font-weight:700; white-space:nowrap; } +@media (max-width: 720px) { + .extensions-heading-actions { width:100%; min-width:0; } + .fee-heading { display:grid; gap:12px; } + .fee-verified-badge { width:max-content; } +} + +/* Vendoo 1.26.1 — Stability, responsive and diagnostics gate */ +.connection-banner { + position: sticky; + top: var(--topbar-height, 68px); + z-index: 70; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; + padding: 10px clamp(14px, 2vw, 28px); + border-bottom: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + background: color-mix(in srgb, var(--danger) 8%, var(--surface)); + color: var(--text); + box-shadow: 0 8px 20px rgb(29 27 24 / 8%); +} +.connection-banner.hidden { display: none; } +.connection-banner-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 14%, transparent); +} +.connection-banner strong, +.connection-banner span { min-width: 0; overflow-wrap: anywhere; } +.connection-banner > span:not(.connection-banner-dot) { color: var(--text-muted); font-size: 12px; } +.connection-banner button { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.system-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; +} +.system-panel-heading h3 { margin: 2px 0 5px; font-size: clamp(20px, 2vw, 27px); } +.system-panel-heading p:last-child { max-width: 760px; margin: 0; color: var(--text-muted); line-height: 1.55; } +.system-panel-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } +.diagnostics-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + min-height: 54px; + margin-bottom: 14px; + padding: 13px 15px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-raised, var(--surface)); +} +.diagnostics-summary span { color: var(--text-muted); font-size: 12px; } +.diagnostics-summary.is-ok { border-color: color-mix(in srgb, #218a55 34%, var(--border)); background: color-mix(in srgb, #218a55 6%, var(--surface)); } +.diagnostics-summary.is-warning, +.diagnostics-summary.is-running { border-color: color-mix(in srgb, #b87911 38%, var(--border)); background: color-mix(in srgb, #b87911 7%, var(--surface)); } +.diagnostics-summary.is-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); } +.diagnostics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 10px; + margin-bottom: 22px; +} +.diagnostic-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + min-width: 0; + padding: 13px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} +.diagnostic-card-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--surface-soft, color-mix(in srgb, var(--text) 5%, transparent)); +} +.diagnostic-card strong, +.diagnostic-card p { min-width: 0; overflow-wrap: anywhere; } +.diagnostic-card p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.45; } +.diagnostic-card-status { padding: 4px 7px; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.diagnostic-card.is-ok .diagnostic-card-status { color: #176b42; background: rgb(33 138 85 / 12%); } +.diagnostic-card.is-error .diagnostic-card-status { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.diagnostic-card.is-warning .diagnostic-card-status, +.diagnostic-card.is-optional .diagnostic-card-status { color: #8b5a08; background: rgb(184 121 17 / 13%); } +.diagnostics-loading { grid-column: 1 / -1; display: flex; justify-content: center; gap: 7px; padding: 30px; } +.diagnostics-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: vd-diagnostic-pulse 900ms infinite alternate; } +.diagnostics-loading span:nth-child(2) { animation-delay: 160ms; } +.diagnostics-loading span:nth-child(3) { animation-delay: 320ms; } +@keyframes vd-diagnostic-pulse { to { opacity: .28; transform: translateY(-4px); } } + +/* Actual workspace-width contracts. These are intentionally independent from viewport width. */ +.main-wrapper, +.main-wrapper main, +.tab-content, +.tab-content > *, +.page-heading, +.form-grid, +.card, +.panel { min-width: 0; max-width: 100%; } +.tab-content img, +.tab-content video, +.tab-content canvas, +.tab-content iframe { max-width: 100%; } +.tab-content input, +.tab-content select, +.tab-content textarea, +.tab-content button { max-width: 100%; } +.tab-content pre, +.tab-content code, +.tab-content td, +.tab-content th { overflow-wrap: anywhere; word-break: break-word; } +.main-wrapper[data-layout-width="phone"] .page-heading, +.main-wrapper[data-layout-width="narrow"] .page-heading, +.tab-content[data-layout-width="phone"] .page-heading, +.tab-content[data-layout-width="narrow"] .page-heading { + align-items: stretch; + flex-direction: column; +} +.main-wrapper[data-layout-width="phone"] .page-heading > *, +.main-wrapper[data-layout-width="narrow"] .page-heading > * { min-width: 0; max-width: 100%; } +.main-wrapper[data-layout-width="phone"] .page-heading button, +.main-wrapper[data-layout-width="phone"] .page-heading select { width: 100%; } +.main-wrapper[data-layout-width="phone"] .toolbar, +.main-wrapper[data-layout-width="narrow"] .toolbar, +.main-wrapper[data-layout-width="phone"] .filter-row, +.main-wrapper[data-layout-width="narrow"] .filter-row { flex-wrap: wrap; min-width: 0; } +.main-wrapper[data-layout-width="phone"] .toolbar > *, +.main-wrapper[data-layout-width="phone"] .filter-row > * { flex: 1 1 100%; min-width: 0; } +.main-wrapper[data-layout-width="phone"] .system-panel-heading, +.main-wrapper[data-layout-width="narrow"] .system-panel-heading { flex-direction: column; } +.main-wrapper[data-layout-width="phone"] .system-panel-actions, +.main-wrapper[data-layout-width="narrow"] .system-panel-actions { width: 100%; justify-content: stretch; } +.main-wrapper[data-layout-width="phone"] .system-panel-actions > *, +.main-wrapper[data-layout-width="narrow"] .system-panel-actions > * { flex: 1 1 180px; } + +@media (max-width: 640px) { + .connection-banner { grid-template-columns: auto minmax(0, 1fr); } + .connection-banner > span:not(.connection-banner-dot) { grid-column: 2; } + .connection-banner button { grid-column: 1 / -1; width: 100%; } + .diagnostic-card { grid-template-columns: auto minmax(0, 1fr); } + .diagnostic-card-status { grid-column: 2; justify-self: start; } +} + +@media (prefers-reduced-motion: reduce) { + .diagnostics-loading span { animation: none; } +} + +/* Vendoo 1.27.0 — Batch Image Factory */ +.image-factory-page { container-type: inline-size; } +.image-factory-heading-actions, +.image-factory-job-tools, +.image-factory-source-tools, +.image-factory-heading-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.image-factory-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(250px, 320px); gap: 18px; align-items: start; } +.surface-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +.image-factory-config, +.image-factory-status, +.image-factory-jobs { padding: clamp(16px, 2vw, 24px); min-width: 0; } +.section-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; } +.section-title-row h3 { margin: 2px 0 0; } +.image-factory-selection { padding: 7px 10px; border-radius: 999px; background: var(--accent-light); color: var(--accent); font-size: 12px; font-weight: 700; white-space: nowrap; } +.image-factory-source-head, +.image-factory-profile-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; margin: 24px 0 10px; } +.image-factory-source-head > div:first-child, +.image-factory-profile-head > div:first-child { display: grid; gap: 3px; } +.image-factory-source-head span, +.image-factory-profile-head span { color: var(--text-muted); font-size: 12px; } +.image-factory-source-tools input, +.image-factory-source-tools select { min-width: 150px; } +.image-factory-source-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); gap: 10px; max-height: 390px; overflow: auto; padding: 2px; } +.image-factory-source-card { position: relative; display: grid; grid-template-rows: minmax(90px, 1fr) auto; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; } +.image-factory-source-card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); transform: translateY(-1px); } +.image-factory-source-card.is-selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); } +.image-factory-source-card > input { position: absolute; opacity: 0; pointer-events: none; } +.image-factory-source-card > img { width: 100%; height: 100%; min-height: 100px; aspect-ratio: 1 / 1; object-fit: cover; background: var(--bg-card); } +.image-factory-source-check { position: absolute; top: 7px; left: 7px; width: 23px; height: 23px; border-radius: 7px; display: grid; place-items: center; color: transparent; background: color-mix(in srgb, var(--bg-card) 92%, transparent); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); box-shadow: 0 2px 8px rgba(0,0,0,.14); } +.image-factory-source-card.is-selected .image-factory-source-check { background: var(--accent); border-color: var(--accent); color: white; } +.image-factory-source-caption { display: grid; gap: 2px; padding: 8px 9px; min-width: 0; } +.image-factory-source-caption strong, +.image-factory-source-caption small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-source-caption strong { font-size: 11px; } +.image-factory-source-caption small { font-size: 10px; color: var(--text-muted); } +.image-factory-profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; } +.image-factory-profile-card { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; min-width: 0; padding: 13px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); } +.image-factory-profile-card.is-selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent); } +.image-factory-profile-select { position: absolute; inset: 0; cursor: pointer; z-index: 1; } +.image-factory-profile-select input { position: absolute; opacity: 0; } +.image-factory-profile-select span { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 7px; color: transparent; border: 1px solid var(--border); background: var(--bg-card); } +.image-factory-profile-card.is-selected .image-factory-profile-select span { color: white; border-color: var(--accent); background: var(--accent); } +.image-factory-profile-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: var(--accent-light); color: var(--accent); font-weight: 800; } +.image-factory-profile-copy { min-width: 0; padding-right: 24px; } +.image-factory-profile-copy strong { font-size: 13px; } +.image-factory-profile-copy p { margin: 4px 0 7px; color: var(--text-secondary); font-size: 11px; line-height: 1.35; } +.image-factory-profile-copy small { color: var(--text-muted); font-size: 10px; } +.image-factory-profile-badge { align-self: end; padding: 3px 7px; border-radius: 999px; background: var(--bg-card); color: var(--text-muted); font-size: 9px; text-transform: uppercase; font-weight: 700; } +.image-factory-profile-actions { position: relative; z-index: 2; display: flex; align-items: flex-end; gap: 4px; } +.image-factory-profile-actions button { border: 0; background: transparent; color: var(--text-muted); font-size: 10px; cursor: pointer; padding: 4px; } +.image-factory-profile-actions button:hover { color: var(--accent); } +.image-factory-options { margin-top: 20px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.image-factory-options summary { cursor: pointer; padding: 13px 15px; font-weight: 700; background: var(--bg-page); } +.image-factory-options-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding: 16px; } +.image-factory-options .toggle-row { align-self: center; } +.image-factory-submit { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--border); } +.image-factory-submit > div { display: grid; gap: 3px; min-width: 0; } +.image-factory-submit span { color: var(--text-muted); font-size: 11px; } +.image-factory-status { position: sticky; top: 18px; } +.image-factory-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; margin-top: 16px; } +.image-factory-stat-grid > div { display: grid; gap: 3px; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-page); } +.image-factory-stat-grid span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.image-factory-stat-grid strong { font-size: 21px; } +.image-factory-help { margin-top: 16px; padding: 13px; border-radius: 11px; background: var(--accent-light); } +.image-factory-help p { margin: 5px 0 0; color: var(--text-secondary); font-size: 11px; line-height: 1.45; } +.image-factory-jobs { margin-top: 18px; } +.image-factory-job-list { display: grid; gap: 10px; } +.image-factory-job { overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); } +.image-factory-job-head { width: 100%; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 13px 14px 10px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; } +.image-factory-job-chevron { font-size: 22px; color: var(--text-muted); transition: transform .18s ease; } +.image-factory-job.is-expanded .image-factory-job-chevron { transform: rotate(90deg); } +.image-factory-job-title { display: grid; min-width: 0; } +.image-factory-job-title strong, +.image-factory-job-title small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-job-title small { color: var(--text-muted); font-size: 10px; margin-top: 3px; } +.image-factory-job-count { font-weight: 700; font-variant-numeric: tabular-nums; } +.image-factory-job-status { padding: 5px 8px; border-radius: 999px; font-size: 10px; font-weight: 800; background: var(--bg-card); } +.image-factory-job.is-running .image-factory-job-status { background: #e8f2ff; color: #175fae; } +.image-factory-job.is-completed .image-factory-job-status { background: #e7f6ed; color: #257046; } +.image-factory-job.is-failed .image-factory-job-status { background: #ffeded; color: #a93838; } +.image-factory-job.is-partial .image-factory-job-status, +.image-factory-job.is-paused .image-factory-job-status { background: #fff5dc; color: #8d6411; } +.image-factory-job-progress { height: 4px; margin: 0 14px; overflow: hidden; border-radius: 99px; background: var(--border); } +.image-factory-job-progress span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .2s ease; } +.image-factory-job-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 6px; padding: 10px 14px 13px; } +.image-factory-job-detail { border-top: 1px solid var(--border); padding: 12px 14px 14px; } +.image-factory-job-loading { color: var(--text-muted); font-size: 12px; } +.image-factory-job-items { display: grid; gap: 7px; } +.image-factory-job-item { display: grid; grid-template-columns: 46px minmax(0, 1fr) auto auto; gap: 9px; align-items: center; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); } +.image-factory-job-item img { width: 46px; height: 46px; object-fit: cover; border-radius: 7px; background: var(--bg-page); } +.image-factory-job-item > div { display: grid; min-width: 0; } +.image-factory-job-item strong, +.image-factory-job-item span, +.image-factory-job-item small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-factory-job-item span, +.image-factory-job-item small { color: var(--text-muted); font-size: 10px; } +.image-factory-job-item small { color: var(--danger); } +.image-factory-item-status { padding: 4px 7px; border-radius: 999px; background: var(--bg-page); font-size: 10px; font-weight: 700; } +.image-factory-pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 16px; } +.image-factory-profile-modal { max-width: 620px; } +.image-factory-profile-form { display: grid; gap: 13px; } + +@container (max-width: 900px) { + .image-factory-layout { grid-template-columns: 1fr; } + .image-factory-status { position: static; } + .image-factory-stat-grid { grid-template-columns: repeat(3, 1fr); } +} +@container (max-width: 680px) { + .image-factory-source-head, + .image-factory-profile-head, + .image-factory-submit, + .section-title-row { align-items: stretch; flex-direction: column; } + .image-factory-source-tools > * { flex: 1 1 160px; min-width: 0; } + .image-factory-options-grid { grid-template-columns: 1fr; } + .image-factory-submit .primary-btn { width: 100%; } + .image-factory-job-head { grid-template-columns: 18px minmax(0, 1fr) auto; } + .image-factory-job-status { grid-column: 2 / -1; justify-self: start; } + .image-factory-job-item { grid-template-columns: 42px minmax(0, 1fr) auto; } + .image-factory-job-item img { width: 42px; height: 42px; } + .image-factory-job-item .vd-button { grid-column: 2 / -1; width: 100%; } +} +@container (max-width: 430px) { + .image-factory-source-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .image-factory-profile-grid { grid-template-columns: 1fr; } + .image-factory-stat-grid { grid-template-columns: repeat(2, 1fr); } + .image-factory-job-actions > * { flex: 1 1 100%; text-align: center; } +} +.media-source-chip.is-batch { background:color-mix(in srgb,var(--accent) 14%,var(--bg-card)); color:var(--accent); } + +/* Vendoo 1.28.0 · Article Quality Center */ +.quality-center-page { container-type: inline-size; } +.quality-center-heading { align-items:flex-start; gap:24px; } +.quality-center-heading .page-heading-actions { display:flex; flex-wrap:wrap; gap:10px; } +.quality-summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; margin-bottom:16px; } +.quality-summary-grid article { min-width:0; padding:18px 20px; border:1px solid var(--border); border-radius:18px; background:linear-gradient(145deg,var(--surface),color-mix(in srgb,var(--surface) 90%,var(--accent) 10%)); box-shadow:var(--shadow-sm); } +.quality-summary-grid span,.quality-summary-grid small { display:block; color:var(--text-muted); font-size:12px; } +.quality-summary-grid strong { display:block; margin:5px 0 2px; font-size:30px; line-height:1; letter-spacing:-.04em; color:var(--text); } +.quality-toolbar { display:grid; grid-template-columns:minmax(240px,1.6fr) repeat(3,minmax(130px,.55fr)); gap:12px; padding:14px; margin-bottom:16px; } +.quality-toolbar label { display:grid; gap:6px; min-width:0; } +.quality-toolbar label>span { font-size:11px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; } +.quality-toolbar input,.quality-toolbar select { width:100%; min-width:0; } +.quality-workspace { display:grid; grid-template-columns:minmax(350px,.88fr) minmax(440px,1.32fr); gap:16px; align-items:start; } +.quality-list-panel,.quality-detail-panel { min-width:0; padding:16px; } +.quality-list { display:grid; gap:8px; } +.quality-list-item { width:100%; display:grid; grid-template-columns:52px minmax(0,1fr) 54px auto; gap:12px; align-items:center; padding:10px; border:1px solid transparent; border-radius:15px; background:var(--surface-2); color:var(--text); text-align:left; cursor:pointer; transition:.18s ease; } +.quality-list-item:hover { transform:translateY(-1px); border-color:color-mix(in srgb,var(--accent) 45%,var(--border)); background:color-mix(in srgb,var(--surface-2) 88%,var(--accent) 12%); } +.quality-list-item.is-active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 16%,transparent); } +.quality-list-thumb { width:52px; aspect-ratio:1; display:grid; place-items:center; overflow:hidden; border-radius:12px; background:var(--surface); color:var(--text-muted); } +.quality-list-thumb img { width:100%; height:100%; object-fit:cover; } +.quality-list-copy { min-width:0; display:grid; gap:3px; } +.quality-list-copy strong,.quality-list-copy small,.quality-list-copy span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.quality-list-copy strong { font-size:14px; } +.quality-list-copy small { color:var(--text-muted); font-size:11px; } +.quality-list-copy span { color:var(--text-soft); font-size:12px; } +.quality-list-score { width:48px; height:48px; border-radius:50%; display:grid; place-content:center; text-align:center; background:var(--surface); border:2px solid var(--border); } +.quality-list-score b { font-size:15px; line-height:1; } +.quality-list-score em { font-style:normal; font-size:9px; color:var(--text-muted); } +.quality-list-score.is-a,.quality-list-score.is-b { border-color:#39a56f; color:#237c51; } +.quality-list-score.is-c { border-color:#c49a32; color:#8d6811; } +.quality-list-score.is-d,.quality-list-score.is-e { border-color:#d45c55; color:#a33a34; } +.quality-list-state { padding:5px 8px; border-radius:999px; font-size:10px; font-weight:800; white-space:nowrap; } +.quality-list-item.is-ready .quality-list-state { background:rgba(57,165,111,.13); color:#278158; } +.quality-list-item.is-review .quality-list-state { background:rgba(196,154,50,.14); color:#8d6811; } +.quality-list-item.is-blocked .quality-list-state { background:rgba(212,92,85,.13); color:#a33a34; } +.quality-pagination { display:flex; justify-content:center; align-items:center; gap:12px; margin-top:14px; } +.quality-detail-panel { position:sticky; top:16px; max-height:calc(100vh - 118px); overflow:auto; scrollbar-width:thin; } +.quality-detail-empty { min-height:420px; display:grid; place-content:center; text-align:center; gap:8px; color:var(--text-muted); padding:30px; } +.quality-empty-icon { width:64px; height:64px; margin:auto; border-radius:20px; display:grid; place-items:center; font-size:28px; background:color-mix(in srgb,var(--accent) 12%,var(--surface-2)); color:var(--accent); } +.quality-detail-content { display:grid; gap:16px; } +.quality-detail-content.is-loading { opacity:.58; pointer-events:none; } +.quality-detail-head { display:grid; grid-template-columns:92px minmax(0,1fr); gap:16px; align-items:center; } +.quality-score-ring { --quality-score:0deg; width:88px; aspect-ratio:1; border-radius:50%; display:grid; place-content:center; text-align:center; background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(var(--accent) var(--quality-score),var(--surface-3) 0); } +.quality-score-ring[data-grade="A"],.quality-score-ring[data-grade="B"] { background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(#39a56f var(--quality-score),var(--surface-3) 0); } +.quality-score-ring[data-grade="D"],.quality-score-ring[data-grade="E"] { background:radial-gradient(circle at center,var(--surface) 61%,transparent 63%),conic-gradient(#d45c55 var(--quality-score),var(--surface-3) 0); } +.quality-score-ring strong { font-size:25px; line-height:1; } +.quality-score-ring span { font-size:10px; color:var(--text-muted); } +.quality-detail-head h3 { margin:3px 0 5px; font-size:20px; overflow-wrap:anywhere; } +.quality-detail-head p { margin:0; color:var(--text-muted); } +.quality-readiness { display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; padding:12px 14px; border-radius:14px; background:var(--surface-2); } +.quality-readiness span { color:var(--text-muted); font-size:12px; } +.quality-detail-actions { display:flex; flex-wrap:wrap; gap:8px; } +.quality-financials { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.quality-financials div { padding:12px; border:1px solid var(--border); border-radius:13px; background:var(--surface-2); min-width:0; } +.quality-financials span { display:block; font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } +.quality-financials strong { display:block; margin-top:4px; overflow-wrap:anywhere; } +.quality-issue-tabs { display:flex; gap:6px; overflow:auto; padding-bottom:2px; } +.quality-issue-tabs button { flex:0 0 auto; border:1px solid var(--border); border-radius:999px; padding:7px 11px; background:var(--surface); color:var(--text-muted); font-size:11px; font-weight:700; } +.quality-issue-tabs button.active { background:var(--text); color:var(--surface); border-color:var(--text); } +.quality-issues { display:grid; gap:8px; } +.quality-issue { display:grid; grid-template-columns:34px minmax(0,1fr) auto; gap:10px; align-items:start; padding:12px; border:1px solid var(--border); border-radius:14px; background:var(--surface-2); } +.quality-issue.is-error { border-left:3px solid #d45c55; } +.quality-issue.is-warning { border-left:3px solid #c49a32; } +.quality-issue.is-recommendation { border-left:3px solid #5181ba; } +.quality-issue-icon { width:30px; height:30px; display:grid; place-items:center; border-radius:9px; background:var(--surface); } +.quality-issue strong { display:block; font-size:13px; } +.quality-issue p { margin:3px 0 4px; color:var(--text-soft); font-size:12px; line-height:1.45; } +.quality-issue small { color:var(--text-muted); font-size:10px; } +.quality-issue-points { font-size:11px; font-weight:800; color:var(--text-muted); } +.quality-all-clear { padding:20px; border-radius:14px; text-align:center; background:rgba(57,165,111,.09); color:#278158; } +.quality-all-clear span { display:block; margin-top:4px; font-size:12px; } +.quality-duplicates,.quality-images,.quality-history { border-top:1px solid var(--border); padding-top:14px; } +.quality-duplicates>div:last-child,.quality-images>div:last-child,.quality-history>div:last-child { display:grid; gap:7px; } +.quality-duplicates button { display:flex; justify-content:space-between; gap:12px; width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:12px; background:var(--surface-2); color:var(--text); text-align:left; } +.quality-duplicates button span { color:var(--text-muted); font-size:11px; } +.quality-image-check { display:grid; grid-template-columns:28px minmax(0,1fr); gap:8px; align-items:center; padding:9px 10px; border-radius:11px; background:var(--surface-2); } +.quality-image-check>span { width:26px; height:26px; border-radius:8px; display:grid; place-items:center; background:var(--surface); color:var(--accent); } +.quality-image-check strong,.quality-image-check small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.quality-image-check small { color:var(--text-muted); font-size:10px; margin-top:2px; } +.quality-history-item { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center; padding:8px 10px; border-radius:10px; background:var(--surface-2); font-size:11px; } +.quality-history-item span { color:var(--text-muted); } +.quality-history-item strong { font-size:14px; } +.quality-history-item em { font-style:normal; color:var(--text-muted); } +#quality-score-delta[data-tone="up"] { color:#278158; } +#quality-score-delta[data-tone="down"] { color:#a33a34; } +.quality-empty-note { margin:0; padding:10px; color:var(--text-muted); font-size:12px; } +.quality-loading { min-height:220px; display:flex; align-items:center; justify-content:center; gap:6px; flex-wrap:wrap; color:var(--text-muted); } +.quality-loading span { width:7px; height:7px; border-radius:50%; background:var(--accent); animation:qualityPulse 1s infinite alternate; } +.quality-loading span:nth-child(2){animation-delay:.15s}.quality-loading span:nth-child(3){animation-delay:.3s}.quality-loading p{flex-basis:100%;text-align:center;margin:4px 0 0} +@keyframes qualityPulse { to { opacity:.25; transform:translateY(-4px); } } +.quality-ai-modal { width:min(1120px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; } +.quality-ai-options { display:flex; flex-wrap:wrap; gap:10px; align-items:end; padding:14px; border-radius:14px; background:var(--surface-2); } +.quality-ai-options label:not(.quality-ai-mode) { display:flex; align-items:center; gap:7px; padding:8px 10px; border:1px solid var(--border); border-radius:10px; background:var(--surface); } +.quality-ai-mode { margin-left:auto; display:grid; gap:5px; min-width:160px; } +.quality-ai-status { padding:11px 13px; border-radius:12px; background:color-mix(in srgb,var(--accent) 9%,var(--surface-2)); color:var(--text-soft); font-size:12px; } +.quality-ai-compare { display:grid; grid-template-columns:1fr 1fr; gap:14px; } +.quality-ai-compare section { min-width:0; display:grid; gap:10px; padding:14px; border:1px solid var(--border); border-radius:16px; background:var(--surface-2); } +.quality-ai-compare h3 { margin:0; } +.quality-ai-compare label { display:grid; gap:5px; font-size:11px; font-weight:700; color:var(--text-muted); } +.quality-ai-compare textarea { width:100%; min-height:62px; resize:vertical; } +.quality-ai-compare label:nth-of-type(2) textarea { min-height:190px; } +.quality-ai-reasons { display:flex; flex-wrap:wrap; gap:7px; } +.quality-ai-reasons span { padding:6px 9px; border-radius:999px; background:var(--surface-2); color:var(--text-muted); font-size:10px; } +.quality-ai-modal footer { display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; } + +@container (max-width: 920px) { + .quality-workspace { grid-template-columns:1fr; } + .quality-detail-panel { position:static; max-height:none; } +} +@container (max-width: 680px) { + .quality-summary-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } + .quality-toolbar { grid-template-columns:1fr 1fr; } + .quality-search { grid-column:1/-1; } + .quality-list-item { grid-template-columns:46px minmax(0,1fr) 48px; } + .quality-list-state { grid-column:2/-1; justify-self:start; } + .quality-financials { grid-template-columns:1fr; } +} +@container (max-width: 460px) { + .quality-summary-grid { grid-template-columns:1fr 1fr; gap:8px; } + .quality-summary-grid article { padding:13px; } + .quality-summary-grid strong { font-size:24px; } + .quality-toolbar { grid-template-columns:1fr; } + .quality-search { grid-column:auto; } + .quality-detail-head { grid-template-columns:72px minmax(0,1fr); } + .quality-score-ring { width:68px; } + .quality-detail-actions>* { flex:1 1 100%; } + .quality-list-copy span { display:none; } +} +@media (max-width: 760px) { + .quality-ai-compare { grid-template-columns:1fr; } + .quality-ai-mode { margin-left:0; width:100%; } +} + +/* Vendoo 1.29.0 — Publishing Hardening */ +.publishing-hardened-header { align-items: flex-start; gap: 18px; } +.publishing-toolbar { display:grid; grid-template-columns:minmax(220px,1fr) repeat(3,minmax(130px,auto)); gap:10px; padding:14px 18px; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--surface) 94%,transparent); } +.publishing-toolbar select,.publishing-toolbar input { min-width:0; width:100%; } +.publishing-selection-bar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:10px 18px; border-bottom:1px solid var(--border); background:var(--surface-soft); } +.publishing-selection-bar > span { margin-right:auto; color:var(--text-muted); font-size:.84rem; } +.publishing-job-list { display:grid; gap:10px; padding:14px; } +.publish-operation-row.publishing-job-row { display:grid; grid-template-columns:28px minmax(210px,1.3fr) minmax(120px,.65fr) minmax(150px,.75fr) minmax(100px,.5fr) minmax(160px,1fr) auto; gap:12px; align-items:center; padding:13px; border:1px solid var(--border); border-radius:14px; background:var(--surface); } +.publishing-job-row.status-failed,.publishing-job-row.status-blocked { border-color:color-mix(in srgb,var(--danger) 45%,var(--border)); } +.publishing-job-row.status-running { border-color:color-mix(in srgb,var(--accent) 50%,var(--border)); } +.publishing-job-check { display:grid; place-items:center; } +.publishing-job-check input { width:16px; height:16px; } +.publishing-job-actions { display:flex; align-items:center; justify-content:flex-end; gap:5px; flex-wrap:wrap; } +.publish-operation-meta small { display:block; color:var(--text-muted); margin-top:3px; font-size:.72rem; } +.publishing-pagination { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:14px 18px; border-top:1px solid var(--border); } +.publishing-pagination > div { display:flex; gap:8px; } +.publishing-error-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; padding:16px 18px; } +.publishing-error-summary article { border:1px solid var(--border); border-radius:14px; padding:16px; background:var(--surface); display:grid; gap:4px; } +.publishing-error-summary strong { font-size:1.65rem; } +.publishing-error-summary span { color:var(--text-muted); font-size:.8rem; } +.publishing-event-list { display:grid; gap:8px; padding:0 18px 18px; } +.publishing-event-row { display:grid; grid-template-columns:10px minmax(0,1fr) auto auto; gap:12px; align-items:center; padding:12px 14px; border:1px solid var(--border); border-radius:12px; background:var(--surface); } +.publishing-event-row.level-error { border-color:color-mix(in srgb,var(--danger) 40%,var(--border)); } +.publishing-event-dot { width:8px; height:8px; border-radius:50%; background:var(--accent); } +.publishing-event-row.level-error .publishing-event-dot { background:var(--danger); } +.publishing-event-row > div { min-width:0; } +.publishing-event-row strong,.publishing-event-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.publishing-event-row small { color:var(--text-muted); margin-top:3px; } +.publishing-event-type { font-size:.72rem; padding:4px 8px; border-radius:999px; background:var(--surface-soft); color:var(--text-muted); } +.operation-status.status-awaiting_user { color:#7b4cc7; background:color-mix(in srgb,#8b5cf6 14%,transparent); } +.operation-status.status-retry_wait { color:#a15c00; background:color-mix(in srgb,#f59e0b 14%,transparent); } +.operation-status.status-ended,.operation-status.status-cancelled { color:var(--text-muted); background:var(--surface-soft); } + +@media (max-width: 1240px) { + .publish-operation-row.publishing-job-row { grid-template-columns:28px minmax(200px,1fr) minmax(130px,.7fr) minmax(150px,.8fr) auto; } + .publishing-job-row .publish-operation-meta:nth-of-type(3), .publishing-job-row .publish-operation-error { grid-column:2 / -2; } + .publishing-job-actions { grid-column:-2 / -1; grid-row:1 / span 2; } +} +@media (max-width: 820px) { + .publishing-toolbar { grid-template-columns:1fr 1fr; } + .publishing-toolbar .publish-search-field { grid-column:1 / -1; } + .publish-operation-row.publishing-job-row { grid-template-columns:24px minmax(0,1fr) auto; align-items:start; } + .publishing-job-row .publish-operation-product { grid-column:2 / 3; } + .publishing-job-row .publish-operation-meta,.publishing-job-row .publish-operation-error { grid-column:2 / -1 !important; } + .publishing-job-actions { grid-column:2 / -1; grid-row:auto; justify-content:flex-start; } + .publishing-error-summary { grid-template-columns:1fr; } + .publishing-event-row { grid-template-columns:10px minmax(0,1fr) auto; } + .publishing-event-type { display:none; } +} +@media (max-width: 520px) { + .publishing-toolbar { grid-template-columns:1fr; } + .publishing-toolbar .publish-search-field { grid-column:auto; } + .publishing-pagination { align-items:stretch; flex-direction:column; } + .publishing-pagination > div,.publishing-pagination button { width:100%; } + .publishing-hardened-header > div:last-child { width:100%; display:grid; grid-template-columns:1fr; } +} + +/* Vendoo 1.30.0 — Operations Center */ +.operations-center { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--border); min-width: 0; } +.operations-center-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; } +.operations-center-head h3 { margin: 3px 0 5px; font-size: 20px; } +.operations-center-head p { margin: 0; color: var(--text-muted); max-width: 760px; } +.operations-tabs { display: flex; gap: 6px; padding: 5px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-page); overflow-x: auto; margin-bottom: 16px; } +.operations-tab { min-height: 38px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); font: inherit; font-weight: 700; white-space: nowrap; cursor: pointer; } +.operations-tab:hover { color: var(--text); background: var(--bg-card); } +.operations-tab.active { color: white; background: var(--accent); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 25%, transparent); } +.operations-panel { display: none; min-width: 0; } +.operations-panel.active { display: block; } +.operations-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.operations-update-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.operations-update-upload { grid-column: 1 / -1; } +.operations-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); box-shadow: 0 8px 24px rgba(20, 24, 35, .04); } +.operations-card > header { display: flex; gap: 11px; align-items: flex-start; margin-bottom: 15px; } +.operations-card > header > span { flex: 0 0 38px; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: color-mix(in srgb, var(--accent) 11%, transparent); } +.operations-card h4, .operations-list-head h4 { margin: 0 0 3px; font-size: 14px; } +.operations-card p, .operations-list-head p { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; } +.operation-field { display: grid; gap: 6px; margin-bottom: 11px; min-width: 0; } +.operation-field > span { font-size: 11px; font-weight: 750; color: var(--text-muted); } +.operation-field input, .operation-field select { width: 100%; min-width: 0; height: 39px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-page); color: var(--text); } +.operation-check { display: flex; gap: 9px; align-items: flex-start; padding: 9px 0; cursor: pointer; } +.operation-check input { margin-top: 3px; accent-color: var(--accent); } +.operation-check span { min-width: 0; } +.operation-check strong, .operation-check small { display: block; } +.operation-check strong { font-size: 12px; } +.operation-check small { margin-top: 2px; color: var(--text-muted); font-size: 10px; line-height: 1.4; } +.operation-check.warning strong { color: var(--warning, #b7791f); } +.operation-inline-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } +.operation-dropzone { display: grid; place-items: center; min-height: 112px; padding: 15px; border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--accent) 4%, var(--bg-page)); text-align: center; cursor: pointer; } +.operation-dropzone:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg-page)); } +.operation-dropzone > span { color: var(--accent); margin-bottom: 6px; } +.operation-dropzone strong, .operation-dropzone small { display: block; } +.operation-dropzone strong { font-size: 12px; } +.operation-dropzone small { margin-top: 4px; color: var(--text-muted); font-size: 10px; } +.operation-file-state { min-height: 30px; margin: 8px 0; padding: 7px 9px; border-radius: 8px; background: var(--bg-page); color: var(--text-muted); font: 600 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.operations-list-head { display: flex; justify-content: space-between; align-items: end; gap: 12px; margin: 20px 0 10px; } +.operations-backup-list { display: grid; gap: 7px; } +.operations-backup-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) minmax(120px, auto) auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-card); min-width: 0; } +.operations-backup-row.is-missing { opacity: .55; } +.operation-backup-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.operation-backup-main { min-width: 0; } +.operation-backup-main strong, .operation-backup-main span, .operation-backup-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operation-backup-main strong { font-size: 12px; } +.operation-backup-main span { margin-top: 2px; color: var(--text-muted); font-size: 10px; } +.operation-backup-main small { margin-top: 2px; color: var(--text-muted); font-size: 9px; } +.operation-backup-hash { color: var(--text-muted); font-size: 9px; } +.operation-backup-actions { display: flex; gap: 5px; } +.operation-backup-actions .icon-button { width: 32px; height: 32px; } +.operations-pending { display: flex; align-items: center; gap: 12px; padding: 12px; margin-bottom: 14px; border: 1px solid color-mix(in srgb, var(--warning, #d97706) 45%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--warning, #d97706) 8%, var(--bg-card)); } +.operations-pending > span { color: var(--warning, #d97706); } +.operations-pending > div { flex: 1; min-width: 0; } +.operations-pending strong, .operations-pending p { display: block; margin: 0; } +.operations-pending p { margin-top: 2px; color: var(--text-muted); font-size: 11px; } +.operation-version-card { display: grid; gap: 4px; padding: 14px; border-radius: 11px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-page)); } +.operation-version-card strong { font-size: 18px; } +.operation-version-card span { color: var(--text-muted); font-size: 10px; } +.operation-migration-list { display: grid; gap: 6px; margin-top: 12px; max-height: 220px; overflow: auto; } +.operation-migration-list > div { display: flex; gap: 8px; align-items: flex-start; padding: 7px; border-radius: 8px; background: var(--bg-page); } +.operation-migration-list > div > span { color: var(--success, #20875d); } +.operation-migration-list p { min-width: 0; } +.operation-migration-list strong, .operation-migration-list small { display: block; } +.operation-migration-list small { color: var(--text-muted); font-size: 9px; } +.operation-card-actions { display: flex; gap: 7px; flex-wrap: wrap; } +.operation-result { display: grid; gap: 3px; min-height: 52px; margin-top: 10px; padding: 10px; border-radius: 9px; background: var(--bg-page); color: var(--text-muted); font-size: 10px; } +.operation-result strong { color: var(--text); font-size: 12px; } +.operations-instruction { display: flex; gap: 11px; padding: 13px; margin-top: 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); } +.operations-instruction > span { color: var(--accent); } +.operations-instruction strong, .operations-instruction p { display: block; margin: 0; } +.operations-instruction p { margin-top: 3px; color: var(--text-muted); font-size: 11px; line-height: 1.5; } +.operations-extension-summary > article { display: flex; gap: 12px; align-items: center; padding: 15px; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); } +.operations-extension-summary > article > span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +.operations-extension-summary strong, .operations-extension-summary p { display: block; margin: 0; } +.operations-extension-summary p { color: var(--text-muted); font-size: 11px; } +.operations-extension-packages { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 10px; } +.operations-extension-packages article { display: flex; gap: 8px; align-items: center; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); } +.operations-extension-packages article > span { color: var(--text-muted); } +.operations-extension-packages article.is-ok > span { color: var(--success, #20875d); } +.operations-extension-packages article.is-warning > span { color: var(--warning, #d97706); } +.operations-extension-packages strong, .operations-extension-packages small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operations-extension-packages strong { font-size: 11px; } +.operations-extension-packages small { color: var(--text-muted); font-size: 9px; } +.operations-extension-clients { display: grid; gap: 7px; } +.operations-extension-client { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); } +.operation-live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); } +.operations-extension-client.is-live .operation-live-dot { background: var(--success, #20875d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success, #20875d) 15%, transparent); } +.operations-extension-client strong, .operations-extension-client span, .operations-extension-client small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.operations-extension-client strong { font-size: 11px; } +.operations-extension-client span, .operations-extension-client small { color: var(--text-muted); font-size: 9px; } +.operations-extension-client b { font-size: 9px; color: var(--text-muted); } +.operations-extension-client.is-live b { color: var(--success, #20875d); } + +@media (max-width: 1180px) { + .operations-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .operations-restore-card { grid-column: 1 / -1; } + .operations-extension-packages { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 760px) { + .operations-center-head { display: grid; } + .operations-center-head .secondary-btn { width: 100%; } + .operations-grid, .operations-update-grid { grid-template-columns: minmax(0, 1fr); } + .operations-restore-card, .operations-update-upload { grid-column: auto; } + .operations-backup-row { grid-template-columns: 36px minmax(0, 1fr) auto; } + .operation-backup-hash { display: none; } + .operations-pending { align-items: flex-start; flex-wrap: wrap; } + .operations-pending .secondary-btn { width: 100%; } +} +@media (max-width: 520px) { + .operation-inline-fields, .operations-extension-packages { grid-template-columns: minmax(0, 1fr); } + .operations-backup-row { grid-template-columns: 32px minmax(0, 1fr); } + .operation-backup-actions { grid-column: 1 / -1; justify-content: flex-end; } + .operations-extension-client { grid-template-columns: 10px minmax(0, 1fr); } + .operations-extension-client b { grid-column: 2; } +} + +/* Vendoo 1.31.0 — Multi-user, RBAC & server security */ +.role-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: 14px; + margin: 18px 0 24px; +} +.role-card, +.security-card, +.security-env-guide { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface, var(--card-bg)); + padding: 16px; + min-width: 0; +} +.role-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.role-card p, +.security-card p, +.security-env-guide p { color: var(--text-muted); margin: 0; line-height: 1.55; } +.role-badge.manager { background: color-mix(in srgb, #7c3aed 14%, transparent); color: #6d28d9; } +.role-badge.publisher { background: color-mix(in srgb, #0284c7 14%, transparent); color: #0369a1; } +.role-badge.viewer { background: color-mix(in srgb, #64748b 16%, transparent); color: #475569; } + +.role-permission-matrix { display: grid; gap: 20px; } +.role-matrix-group h4 { margin: 0 0 9px; } +.role-matrix-table { + border: 1px solid var(--border); + border-radius: 14px; + overflow-x: auto; + background: var(--surface, var(--card-bg)); +} +.role-matrix-head, +.role-matrix-row { + display: grid; + grid-template-columns: minmax(230px, 1.8fr) repeat(5, minmax(96px, .7fr)); + min-width: 760px; + align-items: center; +} +.role-matrix-head { background: var(--surface-soft, rgba(127,127,127,.06)); font-size: 12px; } +.role-matrix-head > *, .role-matrix-row > * { padding: 10px 12px; border-right: 1px solid var(--border); } +.role-matrix-head > *:last-child, .role-matrix-row > *:last-child { border-right: 0; } +.role-matrix-row { border-top: 1px solid var(--border); } +.role-matrix-row i { font-style: normal; text-align: center; font-weight: 800; } +.role-matrix-row i.is-allowed { color: var(--success, #15803d); } +.role-matrix-row i.is-denied { color: var(--text-muted); } + +.security-panel-heading, +.security-lock-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; +} +.security-panel-heading h3, +.security-lock-head h4 { margin: 2px 0 5px; } +.security-panel-heading p, +.security-lock-head p { margin: 0; color: var(--text-muted); } +.security-status-summary { margin-bottom: 12px; } +.security-score { + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + background: var(--surface, var(--card-bg)); +} +.security-score > span { width: 34px; height: 34px; } +.security-score.is-good { border-color: color-mix(in srgb, #16a34a 42%, var(--border)); } +.security-score.is-warning { border-color: color-mix(in srgb, #d97706 48%, var(--border)); } +.security-score strong { display: block; font-size: 16px; } +.security-score p { margin: 3px 0 0; color: var(--text-muted); } +.security-warning-list { display: grid; gap: 8px; margin-bottom: 16px; } +.security-warning, +.security-ok { + display: flex; + gap: 10px; + align-items: flex-start; + border-radius: 12px; + padding: 11px 13px; +} +.security-warning { background: color-mix(in srgb, #f59e0b 11%, transparent); color: #92400e; } +.security-ok { background: color-mix(in srgb, #22c55e 10%, transparent); color: #166534; } +.security-warning p { margin: 0; } +.security-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 22px; } +.security-card h4 { margin: 0 0 12px; } +.security-card dl { display: grid; grid-template-columns: minmax(115px, .8fr) minmax(0, 1.2fr); gap: 9px 14px; margin: 0; } +.security-card dt { color: var(--text-muted); } +.security-card dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.security-lock-list { display: grid; gap: 8px; margin-bottom: 22px; } +.security-lock-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface, var(--card-bg)); +} +.security-lock-row p { margin: 3px 0 0; color: var(--text-muted); font-size: 12px; } +.security-env-guide pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--surface-soft, rgba(127,127,127,.06)); + border: 1px solid var(--border); + border-radius: 12px; + padding: 13px; +} + +.listing-lock-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 14px; + padding: 12px 14px; + border-radius: 13px; + border: 1px solid var(--border); +} +.listing-lock-banner.is-mine { background: color-mix(in srgb, #22c55e 9%, var(--surface, transparent)); border-color: color-mix(in srgb, #22c55e 38%, var(--border)); } +.listing-lock-banner.is-foreign { background: color-mix(in srgb, #f59e0b 10%, var(--surface, transparent)); border-color: color-mix(in srgb, #f59e0b 44%, var(--border)); } +.listing-lock-banner > span { width: 24px; height: 24px; flex: 0 0 auto; } +.listing-lock-banner strong { display: block; } +.listing-lock-banner p { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; } + +.audit-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1.6fr) minmax(150px, .8fr) 145px 145px 135px auto auto; + gap: 8px; + align-items: center; + margin-bottom: 12px; +} +.audit-toolbar > * { min-width: 0; } +.audit-summary { color: var(--text-muted); font-size: 13px; margin-bottom: 8px; } +.audit-user { display: grid; gap: 2px; } +.audit-user small, .audit-action small { color: var(--text-muted); } +.audit-action p { margin: 4px 0 0; color: var(--text-muted); overflow-wrap: anywhere; } +.audit-pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 14px; } + +body[data-user-role="viewer"] .danger-zone, +body[data-user-role="publisher"] .danger-zone { display: none !important; } + +@media (max-width: 1050px) { + .audit-toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .audit-toolbar button { width: 100%; } +} +@media (max-width: 760px) { + .security-grid { grid-template-columns: 1fr; } + .security-panel-heading, .security-lock-head, .security-lock-row { align-items: stretch; flex-direction: column; } + .security-card dl { grid-template-columns: 1fr; gap: 3px; } + .security-card dd { margin-bottom: 8px; } + .audit-toolbar { grid-template-columns: 1fr; } +} diff --git a/public/vendoo-icons.js b/public/vendoo-icons.js new file mode 100644 index 0000000..d262ec6 --- /dev/null +++ b/public/vendoo-icons.js @@ -0,0 +1,67 @@ +/* Vendoo Icons 1.0 — compact local SVG icon set based on Lucide geometry (ISC). */ +(() => { + const ICONS = { + check: '', + heart: '', + 'heart-filled': '', + pencil: '', + mail: '', + plus: '', + download: '', + 'trash-2': '', + 'zoom-in': '', + 'zoom-out': '', + maximize: '', + x: '', + 'triangle-alert': '', + sparkles: '', + image: '', + list: '', + folder: '', + 'folder-open': '', + copy: '', + 'external-link': '', + puzzle: '', + 'shield-check': '', + 'shield-alert': '', + 'alert-triangle': '', + 'database-backup': '', + 'calendar-clock': '', + 'scan-check': '', + 'package-check': '', + 'package-x': '', + 'plug-zap': '', + 'file-archive': '', + 'package-open': '', + 'radio-tower': '', + 'badge-check': '', + 'upload-cloud': '', + 'check-circle-2': '', + 'archive': '', + 'refresh-cw': '', + 'rotate-ccw': '', + 'circle-help': '', + chrome: '', + firefox: '', + compass: '', + edge: '', + }; + + function svg(name, options = {}) { + const size = Number(options.size || 18); + const label = options.label ? ` role="img" aria-label="${String(options.label).replace(/"/g, '"')}"` : ' aria-hidden="true"'; + return `${ICONS[name] || ICONS.image}`; + } + + function render(root = document) { + root.querySelectorAll('[data-vd-icon]').forEach(node => { + const name = node.dataset.vdIcon; + const size = node.dataset.vdIconSize || 18; + node.innerHTML = svg(name, { size }); + node.classList.add('vd-icon-host'); + }); + } + + window.VendooIcons = Object.freeze({ svg, render, names: Object.keys(ICONS) }); + document.addEventListener('DOMContentLoaded', () => render()); +})(); diff --git a/scripts/docker-backup.sh b/scripts/docker-backup.sh new file mode 100644 index 0000000..ad0069c --- /dev/null +++ b/scripts/docker-backup.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +write_sha256() { + file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" > "$file.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" > "$file.sha256" + else + echo "[WARN] Weder sha256sum noch shasum verfügbar; keine Prüfsummendatei erzeugt." >&2 + fi +} + +target="${1:-./vendoo-docker-backup-$(date +%Y%m%d-%H%M%S)}" +mkdir -p "$target" +absolute_target="$(cd "$target" && pwd)" + +docker compose config -q +restart=0 +if docker compose ps --status running --services | grep -qx vendoo; then + restart=1 + docker compose stop vendoo +fi +cleanup() { + code=$? + if [ "$restart" -eq 1 ]; then docker compose up -d vendoo >/dev/null; fi + exit "$code" +} +trap cleanup EXIT INT TERM + +docker compose run --rm --no-deps --user 0:0 \ + -v "$absolute_target":/backup \ + --entrypoint sh vendoo -eu -c \ + 'cd /app/data && tar czf /backup/vendoo-data.tar.gz db uploads backups operations logs' +write_sha256 "$target/vendoo-data.tar.gz" + +trap - EXIT INT TERM +if [ "$restart" -eq 1 ]; then docker compose up -d vendoo >/dev/null; fi +echo "Backup erstellt: $target/vendoo-data.tar.gz" +[ "$restart" -eq 0 ] || echo "Vendoo wurde wieder gestartet." diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..1aacd2d --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +for dir in \ + "$(dirname "${VENDOO_DB_PATH:-/app/data/db/vendoo.db}")" \ + "${VENDOO_UPLOADS_DIR:-/app/data/uploads}" \ + "${VENDOO_BACKUP_DIR:-/app/data/backups}" \ + "${VENDOO_OPERATIONS_DIR:-/app/data/operations}" \ + "${VENDOO_LOG_DIR:-/app/data/logs}" \ + "$(dirname "${VENDOO_CONFIG_PATH:-/app/data/config/vendoo.env}")"; do + mkdir -p "$dir" + test -w "$dir" || { echo "[FATAL] Kein Schreibzugriff auf $dir (Container-UID 1000)." >&2; exit 70; } +done + +if [ "${NODE_ENV:-production}" = "production" ] && [ "${VENDOO_REQUIRE_SETUP_TOKEN:-true}" = "true" ]; then + token="${VENDOO_SETUP_TOKEN:-}" + if [ "${#token}" -lt 32 ]; then + echo "[FATAL] VENDOO_SETUP_TOKEN muss im Produktionsbetrieb mindestens 32 Zeichen lang sein." >&2 + exit 78 + fi +fi + +if [ "${VENDOO_TRUST_PROXY:-false}" != "false" ] && [ -z "${PUBLIC_BASE_URL:-}" ]; then + echo "[WARN] VENDOO_TRUST_PROXY ist aktiv, aber PUBLIC_BASE_URL fehlt. HTTPS-/Cookie-Erkennung sorgfältig prüfen." >&2 +fi + +exec "$@" diff --git a/scripts/docker-restore.sh b/scripts/docker-restore.sh new file mode 100644 index 0000000..e756ffd --- /dev/null +++ b/scripts/docker-restore.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +verify_sha256() { + checksum=$1 + directory=$2 + if command -v sha256sum >/dev/null 2>&1; then + (cd "$directory" && sha256sum -c "$(basename "$checksum")") + elif command -v shasum >/dev/null 2>&1; then + (cd "$directory" && shasum -a 256 -c "$(basename "$checksum")") + else + echo "[WARN] Weder sha256sum noch shasum verfügbar; Prüfsumme kann nicht geprüft werden." >&2 + fi +} + +archive="${1:-}" +confirm="${2:-}" +[ -n "$archive" ] && [ -f "$archive" ] || { + echo "Aufruf: scripts/docker-restore.sh /pfad/vendoo-data.tar.gz --confirm" >&2 + exit 64 +} +[ "$confirm" = "--confirm" ] || { + echo "[ABBRUCH] Restore ersetzt die aktuellen Docker-Daten. Erneut mit --confirm aufrufen." >&2 + exit 64 +} + +archive_dir="$(cd "$(dirname "$archive")" && pwd)" +archive_name="$(basename "$archive")" +archive_abs="$archive_dir/$archive_name" +checksum_file="$archive_abs.sha256" + +docker compose config -q +if [ -f "$checksum_file" ]; then + echo "Prüfe SHA-256 des Restore-Archivs ..." + verify_sha256 "$checksum_file" "$archive_dir" +else + echo "[WARN] Keine Prüfsummendatei $checksum_file gefunden; Archivintegrität kann nicht automatisch bestätigt werden." >&2 +fi + +echo "Erstelle vor dem Restore ein Sicherheitsbackup ..." +pre_restore="./vendoo-pre-restore-$(date +%Y%m%d-%H%M%S)" +"$(dirname "$0")/docker-backup.sh" "$pre_restore" + +echo "Stoppe Vendoo und stelle die persistenten Daten wieder her ..." +docker compose stop vendoo +restart_on_error() { + code=$? + if [ "$code" -ne 0 ]; then + echo "[FEHLER] Restore fehlgeschlagen. Vendoo wird zur Diagnose erneut gestartet." >&2 + docker compose up -d vendoo >/dev/null 2>&1 || true + fi + exit "$code" +} +trap restart_on_error EXIT INT TERM + +docker compose run --rm --no-deps --user 0:0 \ + -v "$archive_dir":/input:ro \ + --entrypoint sh vendoo -eu -c \ + 'for dir in db uploads backups operations logs; do + mkdir -p "/app/data/$dir" + find "/app/data/$dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + done + tar xzf "/input/$1" -C /app/data' sh "$archive_name" + +trap - EXIT INT TERM +docker compose up -d vendoo +echo "Restore eingespielt. Sicherheitsbackup: $pre_restore/vendoo-data.tar.gz" +echo "Jetzt /readyz, Login, Benutzer, Artikel, Bilder und Operations Center prüfen." diff --git a/scripts/github-deploy.ps1 b/scripts/github-deploy.ps1 new file mode 100644 index 0000000..bf2bf16 --- /dev/null +++ b/scripts/github-deploy.ps1 @@ -0,0 +1,183 @@ +# Vendoo 1.36.0 – kontrollierter GitHub-Erstimport +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$RepoUrl = 'https://github.com/Masterluke77/vendoo.git' +$PullRequestUrl = 'https://github.com/Masterluke77/vendoo/pull/9' +$Branch = 'release/1.36.0-theme-manager' +$WorkRoot = Join-Path $env:TEMP ('vendoo-github-deploy-' + [Guid]::NewGuid().ToString('N')) +$CloneDir = Join-Path $WorkRoot 'repo' + +function Info($Text) { Write-Host "[INFO] $Text" -ForegroundColor Cyan } +function Ok($Text) { Write-Host "[OK] $Text" -ForegroundColor Green } +function Warn($Text) { Write-Host "[WARN] $Text" -ForegroundColor Yellow } +function Fail($Text) { Write-Host "[FEHLER] $Text" -ForegroundColor Red } + +function Test-Command([string]$Name) { + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Ensure-Git { + if (Test-Command 'git.exe') { return } + Warn 'Git für Windows wurde nicht gefunden.' + if (Test-Command 'winget.exe') { + $answer = Read-Host 'Git jetzt automatisch über winget installieren? (ja/nein)' + if ($answer -eq 'ja') { + & winget.exe install --id Git.Git --exact --source winget --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw 'Git-Installation über winget ist fehlgeschlagen.' } + $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') + } + } + if (-not (Test-Command 'git.exe')) { throw 'Git für Windows wird benötigt. Starte den Assistenten nach der Git-Installation erneut.' } +} + +function Invoke-Git([string[]]$Arguments, [string]$WorkingDirectory = $CloneDir) { + Push-Location $WorkingDirectory + try { + & git.exe @Arguments + if ($LASTEXITCODE -ne 0) { throw "git $($Arguments -join ' ') fehlgeschlagen (Exit-Code $LASTEXITCODE)." } + } finally { Pop-Location } +} + +function Copy-ProjectClean { + if (-not (Test-Command 'node.exe')) { + throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.' + } + Info 'Bereite den bereinigten Git-Arbeitsordner mit dem plattformübergreifenden Staging-Builder vor ...' + $builder = Join-Path $Root 'tools\prepare-git-staging.mjs' + $result = Invoke-NodeProcess -ScriptPath $builder -Arguments @($Root, $CloneDir) -WorkingDirectory $Root -Prefix 'Staging' + if ($result.ExitCode -ne 0) { + $result.Lines | ForEach-Object { Write-Host $_ -ForegroundColor Red } + throw 'Der sichere Git-Staging-Aufbau ist fehlgeschlagen.' + } + $result.Lines | ForEach-Object { Write-Host $_ } +} + +function Invoke-NodeProcess { + param( + [Parameter(Mandatory=$true)][string]$ScriptPath, + [string[]]$Arguments = @(), + [string]$WorkingDirectory = $CloneDir, + [string]$Prefix = 'Node' + ) + $id = [Guid]::NewGuid().ToString('N') + $stdoutPath = Join-Path $WorkRoot ("$Prefix-$id-stdout.txt") + $stderrPath = Join-Path $WorkRoot ("$Prefix-$id-stderr.txt") + $nodePath = (Get-Command node.exe -ErrorAction Stop).Source + $allArguments = @($ScriptPath) + $Arguments + $argumentLine = ($allArguments | ForEach-Object { '"' + ([string]$_).Replace('"','\"') + '"' }) -join ' ' + $process = Start-Process -FilePath $nodePath ` + -ArgumentList $argumentLine ` + -WorkingDirectory $WorkingDirectory ` + -NoNewWindow ` + -Wait ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + $lines = @() + if (Test-Path -LiteralPath $stdoutPath) { $lines += @(Get-Content -LiteralPath $stdoutPath -Encoding UTF8 -ErrorAction SilentlyContinue) } + if (Test-Path -LiteralPath $stderrPath) { $lines += @(Get-Content -LiteralPath $stderrPath -Encoding UTF8 -ErrorAction SilentlyContinue) } + return [pscustomobject]@{ ExitCode = [int]$process.ExitCode; Lines = $lines } +} + +function Run-SafetyGate { + $ReportDir = Join-Path $Root 'logs' + $ReportPath = Join-Path $ReportDir 'github-deploy-safety-report.txt' + if (-not (Test-Command 'node.exe')) { + throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.' + } + + Info 'Führe das Vendoo Git-Sicherheitsgate über den vollständigen Staging-Arbeitsbaum aus ...' + $result = Invoke-NodeProcess -ScriptPath 'tools/verify-git-safety.mjs' -WorkingDirectory $CloneDir -Prefix 'SafetyGate' + foreach ($line in $result.Lines) { + if ($line -match '^Git-Sicherheitsgate fehlgeschlagen|^- ') { Write-Host $line -ForegroundColor Red } + elseif ($line -match '^\[WARN\]') { Write-Host $line -ForegroundColor Yellow } + else { Write-Host $line } + } + + if ($result.ExitCode -ne 0) { + New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null + @( + 'Vendoo Git-Sicherheitsgate – vollständiger Fehlerbericht', + ('Zeitpunkt: ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')), + ('Projekt: ' + $Root), + ('Staging: ' + $CloneDir), + '', + $result.Lines + ) | Set-Content -LiteralPath $ReportPath -Encoding UTF8 + Fail "Das Gate hat $($result.Lines.Count) Diagnosezeilen erzeugt. Bericht: $ReportPath" + try { Start-Process notepad.exe -ArgumentList ('"' + $ReportPath + '"') | Out-Null } catch {} + throw 'Git-Sicherheitsgate ist fehlgeschlagen. Die konkreten Treffer wurden oben angezeigt und im Bericht gespeichert.' + } + + Remove-Item -LiteralPath $ReportPath -Force -ErrorAction SilentlyContinue + $forbidden = Get-ChildItem -LiteralPath $CloneDir -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { + ($_.Name -match '^\.env(?:\..+)?$' -and $_.Name -notin @('.env.example','.env.docker.example')) -or + $_.Extension -in @('.db','.sqlite','.sqlite3','.log','.key','.pem','.pfx','.p12') + } + if ($forbidden) { + throw ('Verbotene Dateien im Git-Staging: ' + (($forbidden | Select-Object -First 20 -ExpandProperty FullName) -join ', ')) + } + Ok 'Git-Sicherheitsgate erfolgreich: keine lokalen Secrets oder Laufzeitdaten im Import.' +} + +try { + Write-Host '' + Write-Host 'Vendoo 1.36.0 – GitHub Deploy Assistent' -ForegroundColor Green + Write-Host 'Repository: Masterluke77/vendoo (privat)' -ForegroundColor DarkGray + Write-Host '' + + Ensure-Git + New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null + + Info 'Klone die vorhandene GitHub-Baseline ...' + & git.exe clone $RepoUrl $CloneDir + if ($LASTEXITCODE -ne 0) { throw 'Repository konnte nicht geklont werden. Die Git-Anmeldung öffnet sich normalerweise automatisch im Browser.' } + + Push-Location $CloneDir + try { + & git.exe ls-remote --exit-code --heads origin $Branch 1>$null 2>$null + $branchExists = ($LASTEXITCODE -eq 0) + } finally { Pop-Location } + if ($branchExists) { + Invoke-Git @('fetch','origin',$Branch) + Invoke-Git @('switch','--track','-c',$Branch,("origin/" + $Branch)) + } else { + Invoke-Git @('switch','-c',$Branch) + } + + Copy-ProjectClean + Run-SafetyGate + + Invoke-Git @('config','user.name','Markus Müller') + Invoke-Git @('config','user.email','163446896+Masterluke77@users.noreply.github.com') + Invoke-Git @('add','--all') + + Push-Location $CloneDir + try { + $status = & git.exe status --short + if (-not $status) { Warn 'Es gibt keine Änderungen gegenüber GitHub.'; exit 0 } + Write-Host '' + Write-Host $status + Write-Host '' + } finally { Pop-Location } + + $confirm = Read-Host 'Geprüften Vendoo-Stand als Release-Branch zu GitHub übertragen? Zum Fortfahren PUSH eingeben' + if ($confirm -cne 'PUSH') { Warn 'Deployment abgebrochen. Auf GitHub wurde nichts verändert.'; exit 0 } + + Invoke-Git @('commit','-m','fix: import Vendoo 1.36.0 secure theme manager and accessibility foundation') + Invoke-Git @('push','--set-upstream','origin',$Branch) + Ok "Branch $Branch wurde erfolgreich übertragen." + + Info 'Öffne jetzt Draft Pull Request #9 im Browser ...' + Start-Process $PullRequestUrl + Write-Host '' + Write-Host 'Nächster Schritt im Browser: Pull Request #5 und CI prüfen. Erst nach grüner CI nach main mergen.' -ForegroundColor Yellow +} catch { + Fail $_.Exception.Message + exit 1 +} finally { + if (Test-Path -LiteralPath $WorkRoot) { + Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/github-deploy.sh b/scripts/github-deploy.sh new file mode 100644 index 0000000..f73ae0e --- /dev/null +++ b/scripts/github-deploy.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh +set -eu +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +REPO_URL="https://github.com/Masterluke77/vendoo.git" +BRANCH="release/1.36.0-theme-manager" +PR_URL="https://github.com/Masterluke77/vendoo/pull/9" +WORK_DIR=$(mktemp -d) +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT INT TERM + +command -v git >/dev/null 2>&1 || { echo "Git fehlt." >&2; exit 2; } +command -v node >/dev/null 2>&1 || { echo "Node.js wird für den sicheren GitHub-Import benötigt." >&2; exit 2; } + +git clone "$REPO_URL" "$WORK_DIR/repo" +cd "$WORK_DIR/repo" +if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch origin "$BRANCH" + git switch --track -c "$BRANCH" "origin/$BRANCH" +else + git switch -c "$BRANCH" +fi + +node "$ROOT_DIR/tools/prepare-git-staging.mjs" "$ROOT_DIR" "$WORK_DIR/repo" + +GATE_OUTPUT="$WORK_DIR/git-safety-output.txt" +if ! node tools/verify-git-safety.mjs >"$GATE_OUTPUT" 2>&1; then + cat "$GATE_OUTPUT" >&2 + mkdir -p "$ROOT_DIR/logs" + { + echo "Vendoo Git-Sicherheitsgate – vollständiger Fehlerbericht" + echo "Projekt: $ROOT_DIR" + echo + cat "$GATE_OUTPUT" + } > "$ROOT_DIR/logs/github-deploy-safety-report.txt" + echo "Git-Sicherheitsgate fehlgeschlagen. Details: $ROOT_DIR/logs/github-deploy-safety-report.txt" >&2 + exit 1 +fi +cat "$GATE_OUTPUT" +rm -f "$ROOT_DIR/logs/github-deploy-safety-report.txt" + +git config user.name "Markus Müller" +git config user.email "163446896+Masterluke77@users.noreply.github.com" +git add --all +git status --short +printf 'Zum Pushen exakt PUSH eingeben: ' +read -r answer +[ "$answer" = "PUSH" ] || { echo "Abgebrochen."; exit 0; } +git commit -m "feat: import Vendoo 1.36.0 secure theme manager and accessibility foundation" +git push --set-upstream origin "$BRANCH" +echo "Branch übertragen. Draft Pull Request #9 öffnen: $PR_URL" diff --git a/scripts/install-local-flux.ps1 b/scripts/install-local-flux.ps1 new file mode 100644 index 0000000..01ac09e --- /dev/null +++ b/scripts/install-local-flux.ps1 @@ -0,0 +1,803 @@ +param( + [ValidateSet('auto','nvidia','amd','intel','cpu')] + [string]$Gpu = 'auto', + [switch]$InstallModel, + [switch]$EnableAutostart, + [switch]$StartNow, + [switch]$Force, + [switch]$UpdateOnly, + [switch]$NonInteractive +) + +$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$InstallRoot = Join-Path $LocalAiDir 'comfyui' +$PortableRoot = Join-Path $InstallRoot 'ComfyUI_windows_portable' +$ComfyDir = Join-Path $PortableRoot 'ComfyUI' +$EmbeddedPython = Join-Path $PortableRoot 'python_embeded\python.exe' +$ModelDir = Join-Path $ComfyDir 'models\checkpoints' +$ModelPath = Join-Path $ModelDir 'flux1-schnell-fp8.safetensors' +$WorkflowDir = Join-Path $LocalAiDir 'workflows' +$WorkflowPath = Join-Path $WorkflowDir 'vendoo_flux_schnell_api.json' +$LogDir = Join-Path $VendooDir 'logs' +$LogFile = Join-Path $LogDir 'local-flux-install.log' +$StateFile = Join-Path $LocalAiDir 'install-state.json' +$EnvFile = Join-Path $VendooDir '.env' +$DownloadDir = Join-Path $LocalAiDir 'downloads' +$StartScript = Join-Path $LocalAiDir 'start-local-flux.ps1' +$StopScript = Join-Path $LocalAiDir 'stop-local-flux.ps1' +$TaskName = 'Vendoo Local FLUX' +$ComfyPort = 8188 +$ComfyUrl = "http://127.0.0.1:$ComfyPort" +$GitHubApi = 'https://api.github.com/repos/Comfy-Org/ComfyUI/releases/latest' +$ModelUrl = 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true' + +$InstallStartedAt = Get-Date +$LastConsoleProgressAt = [DateTime]::MinValue + +New-Item -ItemType Directory -Force -Path $LocalAiDir,$InstallRoot,$WorkflowDir,$LogDir,$DownloadDir | Out-Null + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + + +function Write-Utf8Bom([string]$Path, [string]$Content) { + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($Path, $Content, $utf8Bom) +} + +function Write-Log([string]$Message, [string]$Level = 'INFO') { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + Add-Content -Path $LogFile -Value $line -Encoding utf8 + if (-not $NonInteractive) { + $color = 'Gray' + if ($Level -eq 'OK') { $color = 'Green' } + elseif ($Level -eq 'WARN') { $color = 'Yellow' } + elseif ($Level -eq 'ERROR') { $color = 'Red' } + Write-Host $line -ForegroundColor $color + } +} + +function Save-State([string]$Status, [string]$Phase, [int]$Progress, [string]$Message, [hashtable]$Extra) { + $previous = $null + if (Test-Path -LiteralPath $StateFile) { + try { $previous = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json } catch {} + } + $startedAt = if ($Phase -eq 'preflight' -and $Status -eq 'running') { $InstallStartedAt.ToString('o') } elseif ($previous -and $previous.started_at) { [string]$previous.started_at } else { $InstallStartedAt.ToString('o') } + $state = [ordered]@{ + status = $Status + phase = $Phase + progress = [math]::Max(0, [math]::Min(100, $Progress)) + message = $Message + started_at = $startedAt + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + elapsed_seconds = [math]::Round(((Get-Date) - ([DateTime]::Parse($startedAt))).TotalSeconds) + install_root = $InstallRoot + comfy_url = $ComfyUrl + model_path = $ModelPath + workflow_path = $WorkflowPath + log_file = $LogFile + requested_install_model = [bool]$InstallModel + requested_enable_autostart = [bool]$EnableAutostart + requested_start_now = [bool]$StartNow + } + if ($Extra) { + foreach ($key in $Extra.Keys) { $state[$key] = $Extra[$key] } + } + $tempState = "$StateFile.tmp" + Write-Utf8NoBom -Path $tempState -Content ($state | ConvertTo-Json -Depth 6) + Move-Item -LiteralPath $tempState -Destination $StateFile -Force +} + +function Set-EnvVar([string]$Key, [string]$Value) { + $content = if (Test-Path $EnvFile) { Get-Content $EnvFile -Raw } else { '' } + $line = "$Key=$Value" + if ($content -match "(?m)^$([regex]::Escape($Key))=") { + $content = [regex]::Replace($content, "(?m)^$([regex]::Escape($Key))=.*$", $line) + } else { + $content = $content.TrimEnd() + "`r`n" + $line + "`r`n" + } + Write-Utf8NoBom -Path $EnvFile -Content $content +} + +function Get-EnvValue([string]$Key) { + if (-not (Test-Path $EnvFile)) { return '' } + $content = Get-Content $EnvFile -Raw + $match = [regex]::Match($content, "(?m)^$([regex]::Escape($Key))=(.*)$") + if ($match.Success) { return $match.Groups[1].Value.Trim() } + return '' +} + +function New-LocalToken { + $bytes = New-Object byte[] 32 + [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_') +} + +function Test-Command([string]$Name) { + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Get-GpuType { + if ($Gpu -ne 'auto') { return $Gpu } + try { + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + $joined = ($names -join ' ').ToLowerInvariant() + if ($joined -match 'nvidia|geforce|quadro|rtx|gtx') { return 'nvidia' } + if ($joined -match 'amd|radeon') { return 'amd' } + if ($joined -match 'intel') { return 'intel' } + } catch {} + return 'cpu' +} + +function Get-FreeSpaceGb([string]$Path) { + try { + $resolved = (Resolve-Path -LiteralPath $Path).Path + $root = [System.IO.Path]::GetPathRoot($resolved) + $drive = New-Object System.IO.DriveInfo -ArgumentList $root + return [math]::Round($drive.AvailableFreeSpace / 1GB, 1) + } catch { + Write-Log "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)" 'WARN' + return 0 + } +} + +function Get-7ZipCandidates { + $items = New-Object System.Collections.Generic.List[string] + try { + $command = Get-Command 7z.exe -ErrorAction SilentlyContinue + if ($command -and $command.Source) { [void]$items.Add([string]$command.Source) } + } catch {} + + $known = New-Object System.Collections.Generic.List[string] + if ($env:ProgramFiles) { [void]$known.Add((Join-Path $env:ProgramFiles '7-Zip\7z.exe')) } + if (${env:ProgramFiles(x86)}) { [void]$known.Add((Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')) } + if ($env:LOCALAPPDATA) { [void]$known.Add((Join-Path $env:LOCALAPPDATA 'Programs\7-Zip\7z.exe')) } + foreach ($path in $known) { + if ($path -and (Test-Path -LiteralPath $path)) { [void]$items.Add([string]$path) } + } + return @($items | Select-Object -Unique) +} + +function Refresh-ProcessPath { + $machine = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [System.Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machine;$user" +} + +function Ensure-7Zip { + Save-State 'running' 'dependency-7zip' 24 '7-Zip wird geprüft.' @{} + $candidates = @(Get-7ZipCandidates) + if ($candidates.Count -gt 0) { + $path = [string]$candidates[0] + Write-Log "7-Zip gefunden: $path" 'OK' + return $path + } + + if (Test-Command 'winget.exe') { + Write-Log '7-Zip fehlt. Installation über winget wird gestartet.' 'WARN' + Save-State 'running' 'install-7zip' 25 '7-Zip wird automatisch installiert.' @{} + & winget.exe install --id 7zip.7zip -e --silent --accept-source-agreements --accept-package-agreements | Out-Null + $wingetExit = $LASTEXITCODE + Refresh-ProcessPath + Start-Sleep -Seconds 2 + $candidates = @(Get-7ZipCandidates) + if ($candidates.Count -gt 0) { + $path = [string]$candidates[0] + Write-Log "7-Zip installiert und gefunden: $path" 'OK' + return $path + } + Write-Log "winget beendete die 7-Zip-Installation mit Exit-Code $wingetExit, aber 7z.exe wurde nicht gefunden." 'WARN' + } + + throw '7-Zip ist erforderlich und konnte nicht automatisch installiert oder gefunden werden. Installiere 7-Zip und starte die FLUX-Installation danach erneut.' +} + +function Get-DirectoryMetrics([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { + return @{ file_count = 0; bytes = [long]0; megabytes = 0 } + } + try { + $files = @(Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue) + $bytes = [long](($files | Measure-Object -Property Length -Sum).Sum) + return @{ + file_count = $files.Count + bytes = $bytes + megabytes = [math]::Round($bytes / 1MB, 1) + } + } catch { + return @{ file_count = 0; bytes = [long]0; megabytes = 0 } + } +} + +function Stop-Stale7ZipForArchive([string]$ArchivePath, [string]$Destination) { + try { + $archiveEscaped = [regex]::Escape($ArchivePath) + $destinationEscaped = [regex]::Escape($Destination) + $items = @(Get-CimInstance Win32_Process -Filter "Name='7z.exe' OR Name='7zG.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and ($_.CommandLine -match $archiveEscaped -or $_.CommandLine -match $destinationEscaped) }) + foreach ($item in $items) { + Write-Log "Beende veralteten 7-Zip-Prozess PID $($item.ProcessId) für diese Installation." 'WARN' + Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Log "Veraltete 7-Zip-Prozesse konnten nicht geprüft werden: $($_.Exception.Message)" 'WARN' + } +} + +function Invoke-7ZipExtract([string]$SevenZipPath, [string]$ArchivePath, [string]$Destination) { + if (-not (Test-Path -LiteralPath $SevenZipPath)) { throw "7-Zip wurde nicht gefunden: $SevenZipPath" } + if (-not (Test-Path -LiteralPath $ArchivePath)) { throw "ComfyUI-Archiv wurde nicht gefunden: $ArchivePath" } + + Stop-Stale7ZipForArchive -ArchivePath $ArchivePath -Destination $Destination + $stdoutFile = Join-Path $LogDir 'local-flux-7zip-output.log' + $stderrFile = Join-Path $LogDir 'local-flux-7zip-error.log' + Remove-Item -LiteralPath $stdoutFile,$stderrFile -Force -ErrorAction SilentlyContinue + + $arguments = "x `"$ArchivePath`" -o`"$Destination`" -y -bsp1 -bso1 -bse1" + Write-Log "Starte 7-Zip-Entpackung: `"$SevenZipPath`" x `"$ArchivePath`" -o`"$Destination`"" 'INFO' + Save-State 'running' 'extract-comfyui' 33 '7-Zip wurde gestartet. ComfyUI wird entpackt.' @{ seven_zip = $SevenZipPath; archive = $ArchivePath; extract_root = $Destination; extract_files = 0; extract_mb = 0 } + + $process = Start-Process -FilePath $SevenZipPath -ArgumentList $arguments -WorkingDirectory $DownloadDir -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + if (-not $process) { throw '7-Zip-Prozess konnte nicht gestartet werden.' } + + $started = Get-Date + $lastReportedFiles = -1 + $lastActivity = Get-Date + $timeoutMinutes = 45 + + while (-not $process.HasExited) { + Start-Sleep -Seconds 5 + $process.Refresh() + $metrics = Get-DirectoryMetrics $Destination + if ($metrics.file_count -ne $lastReportedFiles) { + $lastReportedFiles = $metrics.file_count + $lastActivity = Get-Date + } + $elapsed = [math]::Round(((Get-Date) - $started).TotalSeconds) + $progress = 34 + [math]::Min(16, [math]::Floor($elapsed / 20)) + $message = "ComfyUI wird entpackt: $($metrics.file_count) Dateien · $($metrics.megabytes) MB · ${elapsed}s" + Save-State 'running' 'extract-comfyui' $progress $message @{ + seven_zip = $SevenZipPath + archive = $ArchivePath + extract_root = $Destination + extract_files = $metrics.file_count + extract_bytes = [long]$metrics.bytes + extract_mb = $metrics.megabytes + extract_elapsed_seconds = $elapsed + extract_process_id = $process.Id + extract_stdout = $stdoutFile + extract_stderr = $stderrFile + } + if (-not $NonInteractive -and ((Get-Date) - $script:LastConsoleProgressAt).TotalSeconds -ge 10) { + Write-Host " [..] $message" -ForegroundColor Cyan + $script:LastConsoleProgressAt = Get-Date + } + if (((Get-Date) - $started).TotalMinutes -gt $timeoutMinutes) { + try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {} + throw "7-Zip-Entpackung wurde nach $timeoutMinutes Minuten wegen Zeitüberschreitung abgebrochen." + } + if (((Get-Date) - $lastActivity).TotalMinutes -gt 3 -and $metrics.file_count -eq 0) { + try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {} + throw '7-Zip zeigt seit 3 Minuten keine Extraktionsaktivität. Prüfe Archiv, Virenscanner und freien Speicher.' + } + } + + $process.WaitForExit() + $process.Refresh() + + $rawExitCode = $null + try { + $rawExitCode = $process.ExitCode + } catch { + Write-Log "7-Zip Exit-Code konnte über das Process-Objekt nicht direkt gelesen werden: $($_.Exception.Message)" 'WARN' + } + + $stdoutTail = if (Test-Path -LiteralPath $stdoutFile) { (Get-Content -LiteralPath $stdoutFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' } + $stderrTail = if (Test-Path -LiteralPath $stderrFile) { (Get-Content -LiteralPath $stderrFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' } + $finalMetrics = Get-DirectoryMetrics $Destination + + $exitCode = $rawExitCode + if ($null -eq $exitCode) { + $hasSuccessMarker = $stdoutTail -match '(?im)^Everything is Ok\s*$' + $hasPlausibleResult = ($finalMetrics.file_count -ge 20 -and $finalMetrics.bytes -ge 50MB) + $hasErrorOutput = -not [string]::IsNullOrWhiteSpace($stderrTail) + + if ($hasSuccessMarker -and $hasPlausibleResult -and -not $hasErrorOutput) { + $exitCode = 0 + Write-Log "7-Zip Exit-Code war unter Windows PowerShell 5.1 leer. Erfolg wurde sicher über 'Everything is Ok', leeres Fehlerprotokoll und $($finalMetrics.file_count) Dateien / $($finalMetrics.megabytes) MB bestätigt." 'WARN' + } else { + $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ') + throw "7-Zip-Prozess wurde beendet, aber der Exit-Code konnte nicht ermittelt werden und die Erfolgsprüfung war nicht eindeutig. $details" + } + } + + if ([int]$exitCode -ne 0) { + $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ') + throw "ComfyUI-Archiv konnte nicht entpackt werden (7-Zip Exit-Code $exitCode). $details" + } + if ($finalMetrics.file_count -lt 20 -or $finalMetrics.bytes -lt 50MB) { + throw "7-Zip meldete Erfolg, aber das Entpackungsergebnis ist unvollständig ($($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB)." + } + + Save-State 'running' 'extract-comfyui' 51 "ComfyUI wurde entpackt: $($finalMetrics.file_count) Dateien · $($finalMetrics.megabytes) MB." @{ + seven_zip = $SevenZipPath + archive = $ArchivePath + extract_root = $Destination + extract_files = $finalMetrics.file_count + extract_bytes = [long]$finalMetrics.bytes + extract_mb = $finalMetrics.megabytes + extract_exit_code = $exitCode + } + Write-Log "7-Zip-Entpackung abgeschlossen: $($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB." 'OK' +} + +function Get-LatestPortableAsset([string]$GpuType) { + Write-Log 'Frage aktuelle ComfyUI-Portable-Version über GitHub API ab.' + $release = Invoke-RestMethod -Uri $GitHubApi -Headers @{ 'User-Agent' = 'Vendoo-Local-Flux-Installer' } + $assets = @($release.assets) + if ($assets.Count -eq 0) { throw 'Die aktuelle ComfyUI-Version enthält keine Download-Assets.' } + + $patterns = @() + switch ($GpuType) { + 'nvidia' { $patterns = @('windows_portable.*nvidia.*\.7z$','windows_portable.*cu13.*\.7z$','windows_portable.*\.7z$') } + 'amd' { $patterns = @('windows_portable.*amd.*\.7z$','windows_portable.*\.7z$') } + 'intel' { $patterns = @('windows_portable.*intel.*\.7z$','windows_portable.*\.7z$') } + default { $patterns = @('windows_portable.*\.7z$') } + } + + foreach ($pattern in $patterns) { + $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if ($asset) { return $asset } + } + throw "Kein passendes ComfyUI-Windows-Portable-Paket für GPU-Typ '$GpuType' gefunden." +} + +function Format-DownloadDuration([double]$Seconds) { + if ($Seconds -lt 0 -or [double]::IsInfinity($Seconds) -or [double]::IsNaN($Seconds)) { return 'unbekannt' } + $span = [TimeSpan]::FromSeconds([math]::Round($Seconds)) + if ($span.TotalHours -ge 1) { return ('{0}h {1}m' -f [math]::Floor($span.TotalHours), $span.Minutes) } + if ($span.TotalMinutes -ge 1) { return ('{0}m {1}s' -f [math]::Floor($span.TotalMinutes), $span.Seconds) } + return ('{0}s' -f [math]::Max(0, $span.Seconds)) +} + +function Remove-StaleVendooBitsJobs { + try { + Import-Module BitsTransfer -ErrorAction SilentlyContinue + $jobs = @(Get-BitsTransfer -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'Vendoo *' }) + foreach ($job in $jobs) { + try { Remove-BitsTransfer -BitsJob $job -Confirm:$false -ErrorAction SilentlyContinue } catch {} + } + if ($jobs.Count -gt 0) { + Write-Log "$($jobs.Count) veraltete(r) Vendoo-BITS-Auftrag/Aufträge wurden entfernt." 'WARN' + } + } catch {} +} + +function Invoke-ResumableHttpDownload( + [string]$Url, + [string]$PartialPath, + [string]$Label, + [string]$Phase, + [int]$ProgressStart, + [int]$ProgressEnd +) { + Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue + + $maxAttempts = 5 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $handler = $null + $client = $null + $request = $null + $response = $null + $networkStream = $null + $fileStream = $null + + try { + $resumeFrom = 0L + if (Test-Path -LiteralPath $PartialPath) { + $resumeFrom = [long](Get-Item -LiteralPath $PartialPath).Length + } + + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $true + $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::None + $client = [System.Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromHours(24) + + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url) + [void]$request.Headers.TryAddWithoutValidation('User-Agent', 'Vendoo-Local-Flux-Installer/1.14.5') + if ($resumeFrom -gt 0) { + $request.Headers.Range = [System.Net.Http.Headers.RangeHeaderValue]::Parse("bytes=$resumeFrom-") + Write-Log "$Label wird ab $([math]::Round($resumeFrom / 1MB, 1)) MB fortgesetzt." 'OK' + } + + $response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + $statusCode = [int]$response.StatusCode + + if (-not $response.IsSuccessStatusCode) { + throw "HTTP-Fehler $statusCode ($($response.ReasonPhrase))." + } + + $append = ($resumeFrom -gt 0 -and $statusCode -eq 206) + if ($resumeFrom -gt 0 -and -not $append) { + Write-Log 'Der Downloadserver unterstützt an dieser Stelle keine Fortsetzung. Die Teildatei wird kontrolliert neu begonnen.' 'WARN' + Remove-Item -LiteralPath $PartialPath -Force -ErrorAction SilentlyContinue + $resumeFrom = 0L + } + + $remainingLength = 0L + if ($null -ne $response.Content.Headers.ContentLength) { + $remainingLength = [long]$response.Content.Headers.ContentLength + } + $totalBytes = if ($remainingLength -gt 0) { $resumeFrom + $remainingLength } else { 0L } + + $networkStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $mode = if ($append) { [System.IO.FileMode]::Append } else { [System.IO.FileMode]::Create } + $fileStream = [System.IO.File]::Open($PartialPath, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) + + $buffer = New-Object byte[] (1MB) + $downloaded = $resumeFrom + $lastSampleBytes = $downloaded + $lastSampleAt = Get-Date + $lastStateAt = [DateTime]::MinValue + $lastConsoleAt = [DateTime]::MinValue + $smoothedBytesPerSecond = 0.0 + + while (($read = $networkStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $fileStream.Write($buffer, 0, $read) + $downloaded += $read + $now = Get-Date + + if (($now - $lastSampleAt).TotalSeconds -ge 2) { + $sampleSeconds = [math]::Max(0.1, ($now - $lastSampleAt).TotalSeconds) + $sampleSpeed = ($downloaded - $lastSampleBytes) / $sampleSeconds + if ($smoothedBytesPerSecond -le 0) { $smoothedBytesPerSecond = $sampleSpeed } + else { $smoothedBytesPerSecond = ($smoothedBytesPerSecond * 0.65) + ($sampleSpeed * 0.35) } + $lastSampleBytes = $downloaded + $lastSampleAt = $now + } + + if (($now - $lastStateAt).TotalSeconds -ge 2) { + $percent = 0 + if ($totalBytes -gt 0) { + $percent = [math]::Min(100, [math]::Round(($downloaded / $totalBytes) * 100, 1)) + } + $overall = $ProgressStart + if ($totalBytes -gt 0) { + $overall = $ProgressStart + [math]::Round((($ProgressEnd - $ProgressStart) * $percent) / 100) + } + $downloadedMb = [math]::Round($downloaded / 1MB, 1) + $totalMb = if ($totalBytes -gt 0) { [math]::Round($totalBytes / 1MB, 1) } else { 0 } + $speedMbps = [math]::Round($smoothedBytesPerSecond / 1MB, 1) + $etaSeconds = -1 + if ($totalBytes -gt 0 -and $smoothedBytesPerSecond -gt 0) { + $etaSeconds = [math]::Max(0, ($totalBytes - $downloaded) / $smoothedBytesPerSecond) + } + $etaText = Format-DownloadDuration $etaSeconds + $message = if ($totalBytes -gt 0) { + "${Label}: $percent% · $downloadedMb / $totalMb MB · $speedMbps MB/s · Rest ca. $etaText" + } else { + "${Label}: $downloadedMb MB · $speedMbps MB/s · Gesamtgröße wird ermittelt" + } + + Save-State 'running' $Phase $overall $message @{ + download_percent = $percent + bytes_transferred = [long]$downloaded + bytes_total = [long]$totalBytes + download_speed_mbps = $speedMbps + eta_seconds = [math]::Round($etaSeconds) + download_transport = 'http-stream-resumable' + download_attempt = $attempt + partial_path = $PartialPath + } + + if (-not $NonInteractive) { + $progressPercent = if ($totalBytes -gt 0) { [int][math]::Min(100, [math]::Round($percent)) } else { 0 } + Write-Progress -Activity "$Label wird heruntergeladen" -Status $message -PercentComplete $progressPercent + if (($now - $lastConsoleAt).TotalSeconds -ge 10) { + Write-Host " [DOWNLOAD] $message" -ForegroundColor Cyan + $lastConsoleAt = $now + } + } + $lastStateAt = $now + } + } + + $fileStream.Flush() + if (-not $NonInteractive) { + Write-Progress -Activity "$Label wird heruntergeladen" -Completed + } + return + } catch { + $errorText = $_.Exception.Message + if ($attempt -ge $maxAttempts) { + throw "$Label konnte nach $maxAttempts Versuchen nicht vollständig geladen werden. Letzter Fehler: $errorText" + } + + $currentBytes = 0L + if (Test-Path -LiteralPath $PartialPath) { + try { $currentBytes = [long](Get-Item -LiteralPath $PartialPath).Length } catch {} + } + Write-Log "$Label-Download wurde unterbrochen: $errorText. Automatische Fortsetzung in 5 Sekunden (bereits $([math]::Round($currentBytes / 1MB, 1)) MB)." 'WARN' + Save-State 'running' $Phase $ProgressStart "$Label wird nach einer Netzwerkunterbrechung automatisch fortgesetzt." @{ + bytes_transferred = $currentBytes + download_transport = 'http-stream-resumable' + download_attempt = $attempt + retry_in_seconds = 5 + } + Start-Sleep -Seconds 5 + } finally { + if ($fileStream) { try { $fileStream.Dispose() } catch {} } + if ($networkStream) { try { $networkStream.Dispose() } catch {} } + if ($response) { try { $response.Dispose() } catch {} } + if ($request) { try { $request.Dispose() } catch {} } + if ($client) { try { $client.Dispose() } catch {} } + if ($handler) { try { $handler.Dispose() } catch {} } + } + } +} + +function Download-File([string]$Url, [string]$Destination, [string]$Label, [long]$MinimumBytes = 1) { + $partial = "$Destination.part" + + if (Test-Path -LiteralPath $Destination) { + $size = [long](Get-Item -LiteralPath $Destination).Length + if ($size -ge $MinimumBytes) { + Write-Log "$Label bereits vollständig vorhanden; Download wird übersprungen." 'OK' + return + } + + Write-Log "$Label ist unvollständig. Bereits vorhandene Daten werden für die Fortsetzung übernommen." 'WARN' + if (-not (Test-Path -LiteralPath $partial) -or (Get-Item -LiteralPath $partial).Length -lt $size) { + Move-Item -LiteralPath $Destination -Destination $partial -Force + } else { + Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue + } + } + + Remove-StaleVendooBitsJobs + + $existingBytes = 0L + if (Test-Path -LiteralPath $partial) { + $existingBytes = [long](Get-Item -LiteralPath $partial).Length + } + + $phase = if ($Label -like 'FLUX*') { 'download-model' } else { 'download-comfyui' } + $progressStart = if ($phase -eq 'download-model') { 65 } else { 10 } + $progressEnd = if ($phase -eq 'download-model') { 85 } else { 23 } + + if ($existingBytes -gt 0) { + Write-Log "$Label-Teildownload gefunden: $([math]::Round($existingBytes / 1MB, 1)) MB. Download wird fortgesetzt." 'OK' + } else { + Write-Log "$Label wird mit sichtbarem Fortschritt heruntergeladen." + } + + Save-State 'running' $phase $progressStart "$Label-Download wird vorbereitet." @{ + download_percent = 0 + bytes_transferred = $existingBytes + download_transport = 'http-stream-resumable' + partial_path = $partial + } + + Invoke-ResumableHttpDownload -Url $Url -PartialPath $partial -Label $Label -Phase $phase -ProgressStart $progressStart -ProgressEnd $progressEnd + + if (-not (Test-Path -LiteralPath $partial)) { throw "$Label wurde nicht heruntergeladen." } + $downloadedSize = [long](Get-Item -LiteralPath $partial).Length + if ($downloadedSize -lt $MinimumBytes) { + throw "$Label ist mit $downloadedSize Bytes unerwartet klein und wahrscheinlich unvollständig. Die Teildatei bleibt für eine Fortsetzung erhalten." + } + + Move-Item -LiteralPath $partial -Destination $Destination -Force + Save-State 'running' $phase $progressEnd "$Label vollständig heruntergeladen." @{ + download_percent = 100 + bytes_transferred = $downloadedSize + bytes_total = $downloadedSize + download_transport = 'http-stream-resumable' + } + Write-Log "$Label heruntergeladen ($([math]::Round($downloadedSize / 1MB, 1)) MB)." 'OK' +} + +function Write-Workflow { + $workflow = @' +{ + "3": { "inputs": { "ckpt_name": "flux1-schnell-fp8.safetensors" }, "class_type": "CheckpointLoaderSimple" }, + "4": { "inputs": { "text": "__PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" }, + "5": { "inputs": { "text": "__NEGATIVE_PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" }, + "6": { "inputs": { "width": 1024, "height": 1024, "batch_size": 1 }, "class_type": "EmptyLatentImage" }, + "7": { "inputs": { "seed": "__SEED__", "steps": 4, "cfg": 1, "sampler_name": "euler", "scheduler": "simple", "denoise": 1, "model": ["3", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0] }, "class_type": "KSampler" }, + "8": { "inputs": { "samples": ["7", 0], "vae": ["3", 2] }, "class_type": "VAEDecode" }, + "9": { "inputs": { "filename_prefix": "Vendoo/flux_schnell", "images": ["8", 0] }, "class_type": "SaveImage" } +} +'@ + Write-Utf8NoBom -Path $WorkflowPath -Content $workflow + Write-Log 'FLUX-Prompt-Workflow wurde erstellt.' 'OK' +} + +function Write-StartStopScripts { + $runtimeStartTemplate = Join-Path $ScriptDir 'runtime-start-local-flux.ps1' + $runtimeStopTemplate = Join-Path $ScriptDir 'runtime-stop-local-flux.ps1' + if (-not (Test-Path -LiteralPath $runtimeStartTemplate)) { + throw "FLUX-Runtime-Startvorlage fehlt: $runtimeStartTemplate" + } + if (-not (Test-Path -LiteralPath $runtimeStopTemplate)) { + throw "FLUX-Runtime-Stoppvorlage fehlt: $runtimeStopTemplate" + } + Copy-Item -LiteralPath $runtimeStartTemplate -Destination $StartScript -Force + Copy-Item -LiteralPath $runtimeStopTemplate -Destination $StopScript -Force + Write-Log 'Gehärtete FLUX-Runtime-Skripte wurden installiert.' 'OK' +} + +function Register-AutostartTask { + if (-not $EnableAutostart) { return } + $taskCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartScript`"" + & schtasks.exe /Create /SC ONLOGON /TN $TaskName /TR $taskCommand /F | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Autostart-Aufgabe konnte nicht erstellt werden.' } + Write-Log 'Autostart-Aufgabe für lokalen FLUX-Server erstellt.' 'OK' +} + +function Test-ComfyReachable { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 5 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +try { + Save-State 'running' 'preflight' 2 'Vorprüfung läuft.' @{} + $gpuType = Get-GpuType + $freeGb = Get-FreeSpaceGb $LocalAiDir + Write-Log "Erkannte GPU-Klasse: $gpuType" + Write-Log "Freier Speicher: $freeGb GB" + if ($InstallModel -and $freeGb -lt 28 -and -not $Force) { + throw 'Für ComfyUI plus FLUX.1-schnell FP8 werden mindestens 28 GB freier Speicher empfohlen.' + } + + $alreadyInstalled = (Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py')) + if ($alreadyInstalled -and -not $Force -and -not $UpdateOnly) { + Write-Log 'ComfyUI ist bereits installiert. Neuinstallation wird übersprungen.' 'OK' + } elseif ($UpdateOnly -and $alreadyInstalled) { + Save-State 'running' 'update' 15 'ComfyUI wird aktualisiert.' @{ gpu = $gpuType } + $updateScript = Join-Path $PortableRoot 'update\update_comfyui.bat' + if (Test-Path $updateScript) { + & cmd.exe /d /c "`"$updateScript`"" | Out-Null + Write-Log 'ComfyUI-Update ausgeführt.' 'OK' + } else { + Write-Log 'Kein portables Update-Skript gefunden; aktuelle Installation bleibt unverändert.' 'WARN' + } + } else { + Save-State 'running' 'download-comfyui' 10 'ComfyUI Portable wird geladen.' @{ gpu = $gpuType } + $asset = Get-LatestPortableAsset $gpuType + $archive = Join-Path $DownloadDir $asset.name + Download-File $asset.browser_download_url $archive 'ComfyUI Portable' 100MB + + Save-State 'running' 'extract-comfyui' 30 'ComfyUI Portable wird für die Entpackung vorbereitet.' @{ asset = $asset.name; gpu = $gpuType } + $sevenZip = Ensure-7Zip + $extractTemp = Join-Path $LocalAiDir 'extract-temp' + $reuseExtractedPortable = $false + + if (Test-Path -LiteralPath $extractTemp) { + $resumePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match '^ComfyUI_windows_portable' -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'python_embeded\python.exe')) + } | + Select-Object -First 1 + + if ($resumePortable) { + $resumeMetrics = Get-DirectoryMetrics $extractTemp + if ($resumeMetrics.file_count -ge 20 -and $resumeMetrics.bytes -ge 50MB) { + $reuseExtractedPortable = $true + Write-Log "Vollständiger temporärer ComfyUI-Entpackstand wird wiederverwendet: $($resumeMetrics.file_count) Dateien, $($resumeMetrics.megabytes) MB." 'OK' + Save-State 'running' 'extract-comfyui' 51 "Vorhandener vollständiger Entpackstand wird fortgesetzt: $($resumeMetrics.file_count) Dateien · $($resumeMetrics.megabytes) MB." @{ + seven_zip = $sevenZip + archive = $archive + extract_root = $extractTemp + extract_files = $resumeMetrics.file_count + extract_bytes = [long]$resumeMetrics.bytes + extract_mb = $resumeMetrics.megabytes + extract_reused = $true + extract_exit_code = 0 + } + } + } + + if (-not $reuseExtractedPortable) { + Write-Log 'Vorhandenes temporäres Entpackungsverzeichnis ist nicht vollständig und wird bereinigt.' 'WARN' + Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction Stop + } + } + + if (-not $reuseExtractedPortable) { + New-Item -ItemType Directory -Force -Path $extractTemp | Out-Null + Invoke-7ZipExtract -SevenZipPath $sevenZip -ArchivePath $archive -Destination $extractTemp + } + + Save-State 'running' 'install-comfyui' 52 'Entpackte ComfyUI-Struktur wird geprüft.' @{ asset = $asset.name; gpu = $gpuType; extract_reused = $reuseExtractedPortable } + $sourcePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^ComfyUI_windows_portable' -and (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) } | + Select-Object -First 1 + if (-not $sourcePortable) { + $mainFile = Get-ChildItem -LiteralPath $extractTemp -Filter 'main.py' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '[\\/]ComfyUI[\\/]main\.py$' } | + Select-Object -First 1 + if ($mainFile) { + $comfyFolder = Split-Path -Parent $mainFile.FullName + $sourcePortablePath = Split-Path -Parent $comfyFolder + $sourcePortable = Get-Item -LiteralPath $sourcePortablePath + } + } + if (-not $sourcePortable) { throw 'Entpacktes ComfyUI-Portable-Verzeichnis wurde nicht gefunden. Prüfe local-ai\extract-temp und das 7-Zip-Protokoll.' } + if (Test-Path -LiteralPath $PortableRoot) { Remove-Item -LiteralPath $PortableRoot -Recurse -Force } + Move-Item -LiteralPath $sourcePortable.FullName -Destination $PortableRoot + if (-not (Test-Path -LiteralPath $EmbeddedPython) -or -not (Test-Path -LiteralPath (Join-Path $ComfyDir 'main.py'))) { + throw 'ComfyUI wurde verschoben, aber Python oder main.py fehlen. Die Archivstruktur ist unerwartet.' + } + Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction SilentlyContinue + Save-State 'running' 'install-comfyui' 54 'ComfyUI Portable ist vollständig installiert.' @{ asset = $asset.name; gpu = $gpuType; comfy_installed = $true } + Write-Log 'ComfyUI Portable installiert.' 'OK' + } + + Save-State 'running' 'configure' 55 'Sichere localhost-Konfiguration wird erstellt.' @{ gpu = $gpuType } + Write-Workflow + Write-StartStopScripts + $token = Get-EnvValue 'LOCAL_IMAGE_TOKEN' + if ([string]::IsNullOrWhiteSpace($token)) { $token = New-LocalToken } + Set-EnvVar 'LOCAL_IMAGE_ENABLED' 'true' + Set-EnvVar 'LOCAL_IMAGE_URL' $ComfyUrl + Set-EnvVar 'LOCAL_IMAGE_TOKEN' $token + Set-EnvVar 'LOCAL_IMAGE_WORKFLOW_PATH' 'local-ai/workflows/vendoo_flux_schnell_api.json' + Set-EnvVar 'LOCAL_IMAGE_INSTALL_PATH' 'local-ai/comfyui/ComfyUI_windows_portable' + Set-EnvVar 'LOCAL_IMAGE_REQUIRE_TOKEN' 'true' + + if ($InstallModel) { + Save-State 'running' 'download-model' 65 'FLUX.1-schnell FP8 wird geladen.' @{ gpu = $gpuType } + New-Item -ItemType Directory -Force -Path $ModelDir | Out-Null + Download-File $ModelUrl $ModelPath 'FLUX.1-schnell FP8' 1GB + if ((Get-Item $ModelPath).Length -lt 1GB) { throw 'Die heruntergeladene FLUX-Datei ist unerwartet klein und wahrscheinlich unvollständig.' } + Write-Log 'FLUX.1-schnell FP8 installiert.' 'OK' + } else { + Write-Log 'Modell-Download wurde nicht ausgewählt.' 'WARN' + } + + Save-State 'running' 'autostart' 88 'Autostart wird eingerichtet.' @{ gpu = $gpuType } + Register-AutostartTask + + if ($StartNow) { + Save-State 'running' 'start' 94 'Lokaler FLUX-Server wird gestartet.' @{ gpu = $gpuType } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $StartScript + Start-Sleep -Seconds 8 + } + + $reachable = Test-ComfyReachable + $modelInstalled = Test-Path $ModelPath + $message = 'Lokales FLUX / ComfyUI ist installiert.' + if (-not $modelInstalled) { $message += ' Das FLUX-Modell muss noch installiert werden.' } + if ($StartNow -and -not $reachable) { $message += ' Der Server wurde gestartet, ist aber noch nicht erreichbar; prüfe das Runtime-Log.' } + Save-State 'completed' 'done' 100 $message @{ gpu = $gpuType; comfy_installed = $true; model_installed = $modelInstalled; reachable = $reachable; autostart = [bool]$EnableAutostart } + Write-Log $message 'OK' + exit 0 +} catch { + $message = $_.Exception.Message + $position = $_.InvocationInfo.PositionMessage + $failedProgress = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $previousState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $failedProgress = [int]$previousState.progress + } + } catch {} + Write-Log $message 'ERROR' + if ($position) { Write-Log $position 'ERROR' } + Save-State 'failed' 'error' $failedProgress $message @{ error_position = $position; can_resume = $true; comfy_installed = ((Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py'))); model_installed = (Test-Path $ModelPath) } + exit 1 +} diff --git a/scripts/runtime-start-local-flux.ps1 b/scripts/runtime-start-local-flux.ps1 new file mode 100644 index 0000000..4e44413 --- /dev/null +++ b/scripts/runtime-start-local-flux.ps1 @@ -0,0 +1,210 @@ +param( + [int]$StartupTimeoutSeconds = 180, + [ValidateSet('auto','fast','balanced','lowvram')] + [string]$Profile = 'auto' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if ((Split-Path -Leaf $ScriptDir) -eq 'local-ai') { + $VendooDir = Split-Path -Parent $ScriptDir +} else { + $VendooDir = Split-Path -Parent $ScriptDir +} +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$PortableRoot = Join-Path $LocalAiDir 'comfyui\ComfyUI_windows_portable' +$Python = Join-Path $PortableRoot 'python_embeded\python.exe' +$Main = Join-Path $PortableRoot 'ComfyUI\main.py' +$LogDir = Join-Path $VendooDir 'logs' +$StdoutLog = Join-Path $LogDir 'local-flux-runtime.log' +$StderrLog = Join-Path $LogDir 'local-flux-runtime-error.log' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' +$ComfyUrl = 'http://127.0.0.1:8188' +$ComfyPort = 8188 + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$env:NO_COLOR = '1' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Remove-Ansi([string]$Text) { + if ([string]::IsNullOrEmpty($Text)) { return '' } + $pattern = ([string][char]27) + '\\[[0-?]*[ -/]*[@-~]' + return [regex]::Replace($Text, $pattern, '') +} + +function Read-Tail([string]$Path, [int]$Count = 16) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + try { + return @(Get-Content -LiteralPath $Path -Tail $Count -ErrorAction SilentlyContinue | ForEach-Object { Remove-Ansi ([string]$_) }) + } catch { return @() } +} + +function Save-RuntimeState([string]$Status, [string]$Message, [int]$ProcessId = 0, [int]$Elapsed = 0, [string]$ErrorMessage = '') { + $state = [ordered]@{ + status = $Status + message = $Message + pid = if ($ProcessId -gt 0) { $ProcessId } else { $null } + port = $ComfyPort + url = $ComfyUrl + elapsed_seconds = $Elapsed + updated_at = (Get-Date).ToString('o') + stdout_log = $StdoutLog + stderr_log = $StderrLog + error = $ErrorMessage + stdout_tail = @(Read-Tail $StdoutLog 12) + stderr_tail = @(Read-Tail $StderrLog 16) + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 5) + if ($ProcessId -gt 0) { Write-Utf8NoBom -Path $PidFile -Content ([string]$ProcessId) } +} + + +function Test-ComfyFlag([string]$Flag) { + try { + $help = & $Python -s $Main --help 2>&1 | Out-String + return $help -match [regex]::Escape($Flag) + } catch { return $false } +} + +function Get-NvidiaVramGb { + try { + $line = & nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1 + if ($line -match '^\s*(\d+)') { return [math]::Round(([double]$Matches[1] / 1024), 1) } + } catch {} + return 0 +} + +function Get-PerformanceArguments([string]$RequestedProfile) { + $effective = $RequestedProfile + $vramGb = Get-NvidiaVramGb + if ($effective -eq 'auto') { + if ($vramGb -ge 12) { $effective = 'fast' } + elseif ($vramGb -gt 0 -and $vramGb -lt 8) { $effective = 'lowvram' } + else { $effective = 'balanced' } + } + $flags = New-Object System.Collections.Generic.List[string] + if ($effective -eq 'fast') { + $flags.Add('--highvram') + $flags.Add('--cache-classic') + if (Test-ComfyFlag '--fast') { + $flags.Add('--fast'); $flags.Add('fp16_accumulation'); $flags.Add('cublas_ops') + } + } elseif ($effective -eq 'lowvram') { + $flags.Add('--lowvram') + $flags.Add('--cache-none') + } else { + $flags.Add('--cache-classic') + } + return [pscustomobject]@{ profile = $effective; requested = $RequestedProfile; vram_gb = $vramGb; flags = @($flags) } +} + +function Test-ComfyReady { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 4 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300 + } catch { return $false } +} + +function Get-ComfyProcess { + try { + $items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and + $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and + $_.CommandLine -match "--port\s+$ComfyPort" + } | Select-Object -First 1) + if ($items.Count -gt 0) { return $items[0] } + return $null + } catch { return $null } +} + +try { + if (-not (Test-Path -LiteralPath $Python)) { throw "Embedded Python wurde nicht gefunden: $Python" } + if (-not (Test-Path -LiteralPath $Main)) { throw "ComfyUI main.py wurde nicht gefunden: $Main" } + + if (Test-ComfyReady) { + $existingReady = Get-ComfyProcess + $readyPid = if ($existingReady) { [int]$existingReady.ProcessId } else { 0 } + Save-RuntimeState 'ready' 'FLUX / ComfyUI ist bereits aktiv und erreichbar.' $readyPid 0 + exit 0 + } + + $existing = Get-ComfyProcess + $comfyPid = 0 + $process = $null + if ($existing) { + $comfyPid = [int]$existing.ProcessId + Save-RuntimeState 'starting' 'Ein vorhandener ComfyUI-Prozess initialisiert noch.' $comfyPid 0 + } else { + Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue + $performance = Get-PerformanceArguments $Profile + $arguments = @( + '-s', $Main, + '--listen', '127.0.0.1', + '--port', [string]$ComfyPort, + '--disable-auto-launch', + '--preview-method', 'none', + '--windows-standalone-build', + '--disable-all-custom-nodes', + '--log-stdout', + '--verbose', 'INFO' + ) + foreach ($flag in $performance.flags) { $arguments += $flag } + $process = Start-Process -FilePath $Python -ArgumentList $arguments -WorkingDirectory $PortableRoot -WindowStyle Hidden -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -PassThru + if (-not $process) { throw 'Der ComfyUI-Prozess konnte nicht gestartet werden.' } + $comfyPid = [int]$process.Id + Save-RuntimeState 'starting' ("FLUX / ComfyUI wird initialisiert · Profil: {0} · VRAM: {1} GB." -f $performance.profile, $performance.vram_gb) $comfyPid 0 + } + + $startedAt = Get-Date + while (((Get-Date) - $startedAt).TotalSeconds -lt $StartupTimeoutSeconds) { + Start-Sleep -Seconds 2 + $elapsed = [int][math]::Round(((Get-Date) - $startedAt).TotalSeconds) + + if (Test-ComfyReady) { + Save-RuntimeState 'ready' "FLUX / ComfyUI ist nach ${elapsed}s erreichbar." $comfyPid $elapsed + exit 0 + } + + $alive = $false + try { $alive = $null -ne (Get-Process -Id $comfyPid -ErrorAction SilentlyContinue) } catch {} + if (-not $alive) { + $stderrTail = (Read-Tail $StderrLog 24) -join "`n" + $stdoutTail = (Read-Tail $StdoutLog 24) -join "`n" + $detail = if ($stderrTail) { $stderrTail } elseif ($stdoutTail) { $stdoutTail } else { 'Der Prozess wurde ohne verwertbare Ausgabe beendet.' } + Save-RuntimeState 'failed' 'ComfyUI wurde während des Starts beendet.' $comfyPid $elapsed $detail + throw "ComfyUI wurde während des Starts beendet. $detail" + } + + $tail = @((Read-Tail $StdoutLog 3) + (Read-Tail $StderrLog 3)) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $lastLine = if ($tail.Count -gt 0) { [string]$tail[-1] } else { 'ComfyUI initialisiert Python, Datenbank und Modelle.' } + Save-RuntimeState 'starting' "Start läuft seit ${elapsed}s: $lastLine" $comfyPid $elapsed + } + + $timeoutDetail = @((Read-Tail $StderrLog 24) + (Read-Tail $StdoutLog 24)) -join "`n" + Stop-Process -Id $comfyPid -Force -ErrorAction SilentlyContinue + Save-RuntimeState 'failed' "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet." $comfyPid $StartupTimeoutSeconds $timeoutDetail + throw "ComfyUI war nach ${StartupTimeoutSeconds}s noch nicht erreichbar und wurde beendet. Prüfe $StderrLog und $StdoutLog." +} catch { + $message = Remove-Ansi ([string]$_.Exception.Message) + $currentPid = 0 + try { if (Test-Path -LiteralPath $PidFile) { $currentPid = [int](Get-Content -LiteralPath $PidFile -Raw).Trim() } } catch {} + $elapsed = 0 + try { + if (Test-Path -LiteralPath $StateFile) { + $oldState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json + $elapsed = [int]$oldState.elapsed_seconds + } + } catch {} + Save-RuntimeState 'failed' 'FLUX / ComfyUI konnte nicht gestartet werden.' $currentPid $elapsed $message + Write-Error $message + exit 1 +} diff --git a/scripts/runtime-stop-local-flux.ps1 b/scripts/runtime-stop-local-flux.ps1 new file mode 100644 index 0000000..73ffc6a --- /dev/null +++ b/scripts/runtime-stop-local-flux.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'SilentlyContinue' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LogDir = Join-Path $VendooDir 'logs' +$StateFile = Join-Path $LogDir 'local-flux-runtime-state.json' +$PidFile = Join-Path $LogDir 'local-flux-runtime.pid' + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +$items = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.CommandLine -match 'ComfyUI[\\/]main\.py' -and $_.CommandLine -match '--port\s+8188' +}) +foreach ($item in $items) { Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue } +Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$state = [ordered]@{ + status = 'stopped' + message = 'FLUX / ComfyUI wurde beendet.' + pid = $null + port = 8188 + url = 'http://127.0.0.1:8188' + elapsed_seconds = 0 + updated_at = (Get-Date).ToString('o') + error = '' +} +Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) +exit 0 diff --git a/scripts/start-local-flux.bat b/scripts/start-local-flux.bat new file mode 100644 index 0000000..8a2afe7 --- /dev/null +++ b/scripts/start-local-flux.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0\.." +powershell -ExecutionPolicy Bypass -File "%~dp0\..\local-ai\start-local-flux.ps1" +pause diff --git a/scripts/uninstall-local-flux.ps1 b/scripts/uninstall-local-flux.ps1 new file mode 100644 index 0000000..a42d1ef --- /dev/null +++ b/scripts/uninstall-local-flux.ps1 @@ -0,0 +1,114 @@ +param( + [switch]$NonInteractive, + [switch]$RemoveDownloads +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$VendooDir = Split-Path -Parent $ScriptDir +$LocalAiDir = Join-Path $VendooDir 'local-ai' +$ComfyRoot = Join-Path $LocalAiDir 'comfyui' +$ExtractTemp = Join-Path $LocalAiDir 'extract-temp' +$Downloads = Join-Path $LocalAiDir 'downloads' +$StateFile = Join-Path $LocalAiDir 'install-state.json' +$StartScript = Join-Path $LocalAiDir 'start-local-flux.ps1' +$StopScript = Join-Path $LocalAiDir 'stop-local-flux.ps1' +$TaskName = 'Vendoo Local FLUX' +$LogDir = Join-Path $VendooDir 'logs' +$LogFile = Join-Path $LogDir 'local-flux-uninstall.log' + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +New-Item -ItemType Directory -Force -Path $LocalAiDir | Out-Null + +function Write-Log([string]$Message, [string]$Level = 'INFO') { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 +} + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +try { + Write-Log 'Deinstallation von lokalem FLUX wurde gestartet.' + + if (Test-Path -LiteralPath $StopScript) { + try { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $StopScript | Out-Null + Write-Log 'Vorhandenes Stoppskript wurde ausgeführt.' 'OK' + } catch { + Write-Log "Stoppskript meldete: $($_.Exception.Message)" 'WARN' + } + } + + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -match 'ComfyUI\\main.py' -or + ($_.CommandLine -match [regex]::Escape($VendooDir) -and $_.CommandLine -match 'python_embeded\\python.exe') + }) + foreach ($process in $processes) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + if ($processes.Count -gt 0) { Write-Log "$($processes.Count) ComfyUI-Prozess(e) beendet." 'OK' } + } catch { + Write-Log "Prozessbereinigung meldete: $($_.Exception.Message)" 'WARN' + } + + Start-Sleep -Seconds 2 + & schtasks.exe /Delete /TN $TaskName /F 2>$null | Out-Null + Write-Log 'Autostart-Aufgabe wurde entfernt oder war nicht vorhanden.' 'OK' + + if (Test-Path -LiteralPath $ComfyRoot) { + Remove-Item -LiteralPath $ComfyRoot -Recurse -Force + Write-Log 'ComfyUI und FLUX-Modell wurden entfernt.' 'OK' + } + if (Test-Path -LiteralPath $ExtractTemp) { + Remove-Item -LiteralPath $ExtractTemp -Recurse -Force + Write-Log 'Temporäre Entpackdaten wurden entfernt.' 'OK' + } + foreach ($path in @($StartScript, $StopScript)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } + } + if ($RemoveDownloads -and (Test-Path -LiteralPath $Downloads)) { + Remove-Item -LiteralPath $Downloads -Recurse -Force + Write-Log 'Installationsarchive und Teildownloads wurden entfernt.' 'OK' + } else { + Write-Log 'Installationsarchive bleiben für eine schnelle Neuinstallation erhalten.' 'INFO' + } + + $state = [ordered]@{ + status = 'not-started' + phase = 'uninstalled' + progress = 0 + message = 'Lokales FLUX wurde deinstalliert.' + started_at = $null + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + elapsed_seconds = 0 + can_resume = $false + model_installed = $false + comfy_installed = $false + } + Write-Utf8NoBom -Path $StateFile -Content ($state | ConvertTo-Json -Depth 4) + Write-Log 'Deinstallation erfolgreich abgeschlossen.' 'OK' + exit 0 +} catch { + Write-Log $_.Exception.Message 'ERROR' + try { + $failed = [ordered]@{ + status = 'failed' + phase = 'error' + progress = 0 + message = "Deinstallation fehlgeschlagen: $($_.Exception.Message)" + updated_at = (Get-Date).ToString('o') + heartbeat_at = (Get-Date).ToString('o') + process_id = $PID + } + Write-Utf8NoBom -Path $StateFile -Content ($failed | ConvertTo-Json -Depth 4) + } catch {} + exit 1 +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..43302b9 --- /dev/null +++ b/server.mjs @@ -0,0 +1,3508 @@ +import express from 'express'; +import multer from 'multer'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import sharp from 'sharp'; +import { join, dirname, extname, basename, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID, randomBytes, createHash, timingSafeEqual } from 'crypto'; +import { networkInterfaces } from 'os'; +import { spawn } from 'child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync, readdirSync, copyFileSync } from 'fs'; +import { CONFIG_PATH, readRuntimeConfig, setConfigValue, writeRuntimeConfig, updateRuntimeConfig } from './lib/config-store.mjs'; +import { createPlatformKernel } from './app/kernel/platform-kernel.mjs'; +import { getThemeContract } from './app/core/themes/theme-contract.mjs'; +import { getThemeService } from './app/modules/themes/index.mjs'; +import { + createUser, getUser, getUserByEmail, getUsers, updateUser, deleteUser, + createSession, validateSession, destroySession, destroySessionById, destroyUserSessions, + getUserSessions, getAllActiveSessions, + createAuthToken, validateAuthToken, + checkRateLimit, generateCsrfToken, + auditLog, getAuditLog, searchAuditLog, getAuditLogForUser, + recordLogin, getLoginHistory, + isSetupMode, getUserCount, + verifyPassword, setPassword, validatePasswordStrength, registerFailedLogin, clearFailedLogins, isUserLocked, + cleanExpiredSessions, +} from './lib/auth.mjs'; +import { initMailer, sendMagicLink, sendCredentials, isMailerConfigured, reinitMailer, getMailerConfig, testMailer } from './lib/mailer.mjs'; +import { + createListing, getListing, getListings, updateListing, deleteListing, + softDeleteListing, restoreListing, getDeletedListings, permanentlyDeleteListing, cleanupTrash, getTrashCount, + duplicateListing, + getSettings, updateSettings, + createTemplate, getTemplate, getTemplates, updateTemplate, deleteTemplate, + getListingsForExport, + createPublishLog, updatePublishLog, getPublishLogs, getPublishStatus, + addToEbayQueue, getEbayQueue, updateEbayQueue, removeFromEbayQueue, clearEbayQueue, + schedulePublish, getScheduledPublishes, getDueScheduled, updateScheduledPublish, removeScheduledPublish, + getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation, + getDashboardWorkflow, getDashboardAnalytics, + createMobileUploadSession, getMobileUploadSessionById, getMobileUploadSessionByTokenHash, + appendMobileUploadFiles, updateMobileUploadSession, deleteMobileUploadSession, cleanupMobileUploadSessions, + createAiModelGeneration, updateAiModelGeneration, createAiModelPhoto, getAiModelPhotosForListing, + getAiModelGenerationsForListing, getAiModelPhotosForGeneration, getAiModelJob, + createFluxImage, getFluxImages, paginateFluxImages, getFluxImage, setFluxImageFavorite, deleteFluxImage, + getImageEditVersions, getImageEditRoot, createImageEditVersion, + listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords, db, +} from './lib/db.mjs'; +import * as etsyApi from './lib/etsy-api.mjs'; +import * as ebayApi from './lib/ebay-api.mjs'; +import { wrapInTemplate, getHtmlTemplates, sanitizeRichHtml } from './lib/html-templates.mjs'; +import { prepareForAI, removeBackground, addWatermark } from './lib/images.mjs'; +import { generate, generateText, getProviders } from './lib/ai-router.mjs'; +import { calculateFees, getAllFees } from './lib/fees.mjs'; +import { createZip } from './lib/zip.mjs'; +import { + createBackup, listBackups, getBackupPath, verifyStoredBackup, deleteBackup, stageRestore, + stageUpdate, getOperationsStatus, cancelPendingOperation, checkForUpdates, + updateOperationSettings, recordExtensionHeartbeat, getExtensionDiagnostics, + startAutomaticBackupLoop, operationsPaths, +} from './lib/operations-center.mjs'; +import { + analyzeListingQuality, listQualityCenter, getQualityHistory, compareQualityReports, +} from './lib/quality-center.mjs'; +import { getCategories, getCategoriesFlat, matchCategories, getAllPlatformIds } from './lib/categories.mjs'; +import { precheckAiModelPhotos, generateAiModelPhotos, fetchOpenRouterImageModels, getOpenRouterImageDiagnostics } from './lib/ai-model-photos.mjs'; +import { getLocalImageStatus, getLocalFluxRuntimeInfo, generateLocalImageToken, ensureLocalFluxBootstrap } from './lib/ai-local-images.mjs'; +import { renderImageVersion, resolveEditableImage } from './lib/image-editor.mjs'; +import { enqueueAiModelJob, getAiModelJobWithRelations, startAiModelJobLoop } from './lib/ai-model-jobs.mjs'; +import { + enqueueFluxPromptJob, getFluxPromptJob, listFluxPromptJobs, paginateFluxPromptJobs, getFluxPromptJobSummary, + cancelFluxPromptJob, retryFluxPromptJob, clearFluxPromptQueue, archiveFluxPromptJobs, restoreFluxPromptJobs, + deleteArchivedFluxPromptJobs, startFluxPromptJobLoop, +} from './lib/ai-flux-prompt-jobs.mjs'; +import { + listImageBatchProfiles, createImageBatchProfile, updateImageBatchProfile, deleteImageBatchProfile, + createImageBatchJob, getImageBatchJob, paginateImageBatchJobs, pauseImageBatchJob, resumeImageBatchJob, + cancelImageBatchJob, retryImageBatchJob, retryImageBatchItem, deleteImageBatchJob, getImageBatchOutputPaths, + startImageBatchJobLoop, +} from './lib/image-batch-jobs.mjs'; +import { + enqueuePublishingJob, getPublishingJob, paginatePublishingJobs, listPublishingEvents, + retryPublishingJob, cancelPublishingJob, deletePublishingJobs, claimExtensionJob, completeExtensionJob, + configurePublishingProcessors, startPublishingJobLoop, recoverPublishingJobs, migrateLegacyEbayQueue, getPublishCapabilities, +} from './lib/publishing-jobs.mjs'; +import { + getRoleDefinitions, getPermissionCatalog, permissionsForRole, hasPermission, requiredPermissionForApi, + acquireResourceLock, getResourceLock, listResourceLocks, renewResourceLock, releaseResourceLock, + validateResourceLock, consumeRateLimit, getRateLimitStats, +} from './lib/security.mjs'; +import { + APP_VERSION, DB_PATH, UPLOADS_DIR, OPERATIONS_DIR, LOG_DIR, + ensureRuntimeDirectories, verifyRuntimeWritable, runtimePathSummary, +} from './lib/runtime-paths.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = express(); +const PORT = Number(process.env.PORT || 8124); +ensureRuntimeDirectories(); +let shuttingDown = false; + +const platformKernel = createPlatformKernel({ version: APP_VERSION, hasPermission }); +await platformKernel.start(); + +startAiModelJobLoop(); +startFluxPromptJobLoop(); +startImageBatchJobLoop(); +startAutomaticBackupLoop(); +const legacyPublishingMigration = migrateLegacyEbayQueue(); +if (legacyPublishingMigration.migrated) console.log(`Publishing: ${legacyPublishingMigration.migrated} alte eBay-Queue-Einträge übernommen`); +recoverPublishingJobs(); +configurePublishingProcessors({ + async process(job, listing) { + const payload = job.payload || {}; + const previous = db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND id != ? + AND status IN ('published','ended') ORDER BY completed_at DESC, created_at DESC LIMIT 1`).get(job.listing_id, job.platform, job.id); + const offerId = job.offer_id || payload.offerId || previous?.offer_id || null; + const externalId = job.external_id || payload.externalId || previous?.external_id || null; + + if (job.platform === 'ebay-de') { + if (!ebayApi.isConnected()) throw new Error('eBay ist nicht verbunden'); + const sku = listing.sku || `AL-${listing.id}`; + if (job.action === 'publish') return ebayApi.createAndPublish(listing, sku); + if (job.action === 'update') { + if (!offerId) throw new Error('Keine eBay Offer-ID für die Aktualisierung vorhanden'); + const offer = await ebayApi.updateOffer(listing, offerId, sku); + return { offerId, externalId: offer?.listing?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'published', offer }; + } + if (job.action === 'end') { + if (!offerId) throw new Error('Keine eBay Offer-ID zum Beenden vorhanden'); + const result = await ebayApi.withdrawOffer(offerId); + return { offerId, externalId: result?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'ended', result }; + } + if (job.action === 'sync') { + if (!offerId) throw new Error('Keine eBay Offer-ID für den Statusabgleich vorhanden'); + const offer = await ebayApi.getOffer(offerId); + const status = String(offer?.status || '').toUpperCase() === 'PUBLISHED' ? 'published' : 'ended'; + const listingId = offer?.listing?.listingId || externalId; + return { offerId, externalId: listingId, url: listingId ? `https://www.ebay.de/itm/${listingId}` : null, status, offer }; + } + } + + if (job.platform === 'etsy') { + if (!etsyApi.isConnected()) throw new Error('Etsy ist nicht verbunden'); + if (job.action === 'publish') { + const shopId = await etsyApi.getShopId(); + const created = await etsyApi.createEtsyListing(listing, shopId); + if (listing.photos?.length) { + for (const photo of listing.photos) { + const photoPath = join(UPLOADS_DIR, photo); + if (!existsSync(photoPath)) continue; + await etsyApi.uploadEtsyImage(shopId, created.listing_id, readFileSync(photoPath), photo); + } + } + return { externalId: String(created.listing_id), url: `https://www.etsy.com/listing/${created.listing_id}`, status: created.state || 'draft', listing: created }; + } + if (job.action === 'sync') { + if (!externalId) throw new Error('Keine Etsy Listing-ID für den Statusabgleich vorhanden'); + const item = await etsyApi.getEtsyListing(externalId); + const status = ['active', 'draft'].includes(item?.state) ? 'published' : 'ended'; + return { externalId: String(externalId), url: `https://www.etsy.com/listing/${externalId}`, status, listing: item }; + } + throw new Error(`Aktion ${job.action} wird für Etsy derzeit nicht unterstützt`); + } + throw new Error(`Keine offizielle API-Verarbeitung für ${job.platform}`); + }, +}); +startPublishingJobLoop(); + + +function plainTextToSafeHtml(text) { + const escaped = String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return escaped.split(/\n{2,}/).map(part => `

                                                                      ${part.replace(/\n/g, '
                                                                      ')}

                                                                      `).join(''); +} + +function htmlToPlainText(html) { + return String(html || '') + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function normalizeListingPayload(payload = {}) { + const normalized = { ...payload }; + if (normalized.description_html !== undefined) { + normalized.description_html = sanitizeRichHtml(normalized.description_html); + if (normalized.description === undefined) normalized.description = htmlToPlainText(normalized.description_html); + } + if (normalized.description !== undefined) normalized.description = String(normalized.description || '').trim(); + if (normalized.tags !== undefined && !Array.isArray(normalized.tags)) { + normalized.tags = String(normalized.tags || '').split(',').map(tag => tag.trim()).filter(Boolean); + } + if (normalized.photos !== undefined && !Array.isArray(normalized.photos)) normalized.photos = []; + return normalized; +} + + +function hashMobileUploadToken(token) { + return createHash('sha256').update(String(token || '')).digest('hex'); +} + +function isMobileUploadSessionUsable(session) { + if (!session || session.status !== 'active') return false; + const expiresAt = Date.parse(String(session.expires_at || '').replace(' ', 'T') + 'Z'); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +} + +function getLanIpv4Addresses() { + const addresses = []; + const interfaces = networkInterfaces(); + for (const [name, entries] of Object.entries(interfaces)) { + for (const entry of entries || []) { + const family = typeof entry.family === 'string' ? entry.family : (entry.family === 4 ? 'IPv4' : String(entry.family)); + if (family !== 'IPv4' || entry.internal) continue; + const address = String(entry.address || '').trim(); + if (!address || address.startsWith('169.254.')) continue; + const isPrivate = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(address); + const lowerName = String(name || '').toLowerCase(); + const isVirtual = /(virtual|vmware|hyper-v|vethernet|wsl|docker|vpn|tailscale|zerotier|loopback|bluetooth)/i.test(lowerName); + const isPrimaryAdapter = /(ethernet|wlan|wi-fi|wifi|lan)/i.test(lowerName); + const score = (isPrivate ? 100 : 0) + (isPrimaryAdapter ? 30 : 0) - (isVirtual ? 80 : 0); + addresses.push({ name, address, isPrivate, isVirtual, score }); + } + } + addresses.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + return addresses; +} + +function resolvePublicBaseUrl(req) { + const configured = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, ''); + if (configured) { + try { + const configuredUrl = new URL(configured); + const configuredHost = configuredUrl.hostname.toLowerCase(); + if (!['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(configuredHost)) return configured; + } catch {} + } + + const forwardedProto = String(req.get('x-forwarded-proto') || '').split(',')[0].trim(); + const protocol = forwardedProto || req.protocol || 'http'; + const rawHost = String(req.get('x-forwarded-host') || req.get('host') || `localhost:${PORT}`).split(',')[0].trim(); + const hostOnly = rawHost.replace(/^\[/, '').replace(/\](:\d+)?$/, '').replace(/:\d+$/, '').toLowerCase(); + const isLoopback = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostOnly); + if (!isLoopback) return `${protocol}://${rawHost}`.replace(/\/$/, ''); + + const lan = getLanIpv4Addresses()[0]; + const portMatch = rawHost.match(/:(\d+)$/); + const port = portMatch?.[1] || String(PORT); + if (lan?.address) return `${protocol}://${lan.address}:${port}`; + return `${protocol}://${rawHost}`.replace(/\/$/, ''); +} + +function parseTrustProxy(value) { + const raw = String(value || 'false').trim().toLowerCase(); + if (['false', '0', 'no', 'off', ''].includes(raw)) return false; + if (['true', 'yes', 'on'].includes(raw)) return 1; + if (/^\d+$/.test(raw)) return Math.max(0, Number(raw)); + if (['loopback', 'linklocal', 'uniquelocal'].includes(raw)) return raw; + throw new Error('VENDOO_TRUST_PROXY muss false, true, eine Hop-Anzahl oder loopback/linklocal/uniquelocal sein.'); +} +const TRUST_PROXY = parseTrustProxy(process.env.VENDOO_TRUST_PROXY); +if (TRUST_PROXY !== false) app.set('trust proxy', TRUST_PROXY); + +const SECURITY_CSP = { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'", 'http://127.0.0.1:*', 'http://localhost:*'], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], +}; +app.use(helmet({ + contentSecurityPolicy: { directives: SECURITY_CSP }, + crossOriginEmbedderPolicy: false, + referrerPolicy: { policy: 'same-origin' }, +})); +app.use(express.json({ limit: '2mb' })); +app.use(cookieParser()); +app.use((req, res, next) => { + const requestId = String(req.get('x-vendoo-request-id') || randomUUID()).slice(0, 100); + req.requestId = requestId; + res.setHeader('X-Vendoo-Request-ID', requestId); + res.setHeader('Cache-Control', req.path.startsWith('/api/') || req.path.startsWith('/auth/') ? 'no-store' : 'private, max-age=0, must-revalidate'); + next(); +}); + +function configuredAllowedOrigins() { + return new Set(String(process.env.VENDOO_ALLOWED_ORIGINS || '') + .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); +} + +function configuredExtensionOrigins() { + return new Set(String(process.env.VENDOO_EXTENSION_ORIGINS || '') + .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); +} + +function isAllowedOrigin(req, origin) { + if (!origin) return true; + const normalized = origin.replace(/\/$/, ''); + if (/^(chrome-extension|moz-extension|safari-web-extension):\/\//i.test(normalized)) { + const allowAny = /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || (process.env.NODE_ENV === 'production' ? 'false' : 'true'))); + return allowAny || configuredExtensionOrigins().has(normalized); + } + const ownOrigin = `${req.protocol}://${req.get('host')}`.replace(/\/$/, ''); + if (normalized === ownOrigin) return true; + return configuredAllowedOrigins().has(normalized); +} + +function isAllowedHost(hostHeader) { + const raw = String(hostHeader || '').trim(); + let host = raw; + if (raw.startsWith('[')) host = raw.slice(1, raw.indexOf(']') > 0 ? raw.indexOf(']') : undefined); + else if ((raw.match(/:/g) || []).length === 1) host = raw.replace(/:\d+$/, ''); + host = host.toLowerCase(); + if (!host) return false; + const configured = new Set(String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean)); + if (configured.has(host)) return true; + if (['localhost', '127.0.0.1', '::1'].includes(host)) return true; + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return true; + try { + const publicHost = new URL(String(process.env.PUBLIC_BASE_URL || '')).hostname.toLowerCase(); + if (publicHost && host === publicHost) return true; + } catch {} + return configured.size === 0 && !/^[a-z0-9.-]+$/i.test(host) ? false : configured.has(host); +} + +app.use((req, res, next) => { + if (!isAllowedHost(req.get('host'))) return res.status(421).json({ error: 'Host nicht freigegeben. VENDOO_ALLOWED_HOSTS prüfen.' }); + const origin = req.get('origin'); + if (origin && !isAllowedOrigin(req, origin)) return res.status(403).json({ error: 'Origin nicht freigegeben.' }); + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Token,X-Vendoo-Request-ID,X-Edit-Lock'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +app.get('/healthz', (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.json({ ok: true, version: APP_VERSION, uptime_seconds: Math.floor(process.uptime()), time: new Date().toISOString() }); +}); + +app.get('/readyz', async (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const checks = []; + try { + const dbCheck = db.prepare('SELECT 1 AS ok').get(); + checks.push({ name: 'database', ok: Number(dbCheck?.ok) === 1, path: DB_PATH }); + } catch (error) { + checks.push({ name: 'database', ok: false, path: DB_PATH, error: error.message }); + } + for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, ...item }); + try { + const kernel = await platformKernel.health(); + checks.push({ name: 'platform-kernel', ok: kernel.ok, lifecycle: kernel.lifecycle.state, modules: kernel.modules.length }); + } catch (error) { + checks.push({ name: 'platform-kernel', ok: false, error: error.message }); + } + const ok = !shuttingDown && checks.every(item => item.ok !== false); + res.status(ok ? 200 : 503).json({ ok, shutting_down: shuttingDown, version: APP_VERSION, checks, paths: runtimePathSummary(), time: new Date().toISOString() }); +}); + +// Init mailer — check DB settings first, fallback to .env +function initMailerFromDbOrEnv() { + try { + const settings = getSettings(); + if (settings.smtp_host && settings.smtp_user && settings.smtp_pass) { + const ok = reinitMailer({ + host: settings.smtp_host, + port: settings.smtp_port || '587', + user: settings.smtp_user, + pass: settings.smtp_pass, + }); + if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from; + if (ok) return; + } + } catch {} + initMailer(); +} +initMailerFromDbOrEnv(); + +// --- Auth Middleware --- +function getClientIp(req) { + return TRUST_PROXY ? String(req.ip || '') : String(req.socket?.remoteAddress || ''); +} + +function requireAuth(req, res, next) { + if (isSetupMode()) { + if (req.originalUrl.startsWith('/api')) return res.status(503).json({ error: 'Vendoo muss zuerst über /login.html eingerichtet werden.', setup_required: true }); + return res.redirect('/login.html'); + } + + const token = req.cookies?.auth_token; + const session = validateSession(token); + if (!session) { + if (req.originalUrl.startsWith('/api')) return res.status(401).json({ error: 'Nicht eingeloggt' }); + return res.redirect('/login.html'); + } + req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, avatar_color: session.avatar_color, permissions: [...permissionsForRole(session.role)] }; + next(); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Keine Berechtigung' }); + next(); + }; +} + +function csrfProtect(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const token = req.headers['x-csrf-token'] || req.body?._csrf; + const cookie = req.cookies?.csrf_token; + if (!token || !cookie || token !== cookie) { + return res.status(403).json({ error: 'Ungültiger CSRF-Token' }); + } + next(); +} + +function requirePermission(permission) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + if (!hasPermission(req.user.role, permission)) { + auditLog(req.user.id, req.user.email, 'security.permission_denied', 'route', req.path, JSON.stringify({ permission, method: req.method }), getClientIp(req)); + return res.status(403).json({ error: 'Diese Aktion ist für deine Rolle nicht freigegeben.', permission }); + } + next(); + }; +} + +function apiPermissionGuard(req, res, next) { + const routePath = String(req.originalUrl || req.path || '').split('?')[0]; + const permission = requiredPermissionForApi(req.method, routePath); + if (!permission || hasPermission(req.user?.role, permission)) return next(); + auditLog(req.user?.id || null, req.user?.email || null, 'security.permission_denied', 'route', routePath, JSON.stringify({ permission, method: req.method }), getClientIp(req)); + return res.status(403).json({ error: 'Keine Berechtigung für diese Aktion.', permission }); +} + +function apiRateLimit(req, res, next) { + const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(req.method); + const limit = mutation ? Number(process.env.VENDOO_MUTATION_RATE_LIMIT || 120) : Number(process.env.VENDOO_READ_RATE_LIMIT || 360); + const key = `api:${req.user?.id || getClientIp(req)}:${mutation ? 'write' : 'read'}`; + const result = consumeRateLimit(key, { limit: Math.max(20, limit), windowMs: 60_000 }); + res.setHeader('X-RateLimit-Remaining', String(result.remaining)); + if (!result.allowed) { + res.setHeader('Retry-After', String(result.retryAfter)); + return res.status(429).json({ error: 'Zu viele Anfragen. Bitte kurz warten.', retry_after: result.retryAfter }); + } + next(); +} + +function secureCookieOptions(req) { + const forwardedHttps = TRUST_PROXY !== false && String(req.get('x-forwarded-proto') || '').split(',')[0].trim() === 'https'; + const secureMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase(); + const detectedSecure = Boolean(req.secure || forwardedHttps || /^https:\/\//i.test(String(process.env.PUBLIC_BASE_URL || ''))); + const secure = secureMode === 'true' ? true : secureMode === 'false' ? false : detectedSecure; + const sameSiteValue = String(process.env.VENDOO_COOKIE_SAMESITE || 'lax').trim().toLowerCase(); + const sameSite = ['lax', 'strict', 'none'].includes(sameSiteValue) ? sameSiteValue : 'lax'; + if (sameSite === 'none' && !secure) throw new Error('VENDOO_COOKIE_SAMESITE=none erfordert sichere HTTPS-Cookies.'); + const maxAge = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000; + return { httpOnly: true, secure, sameSite, maxAge, path: '/' }; +} + +function csrfCookieOptions(req) { + return { ...secureCookieOptions(req), httpOnly: false }; +} + +const SETUP_TOKEN = String(process.env.VENDOO_SETUP_TOKEN || '').trim(); +function setupTokenMatches(value) { + if (!SETUP_TOKEN) return true; + const provided = Buffer.from(String(value || '')); + const expected = Buffer.from(SETUP_TOKEN); + return provided.length === expected.length && timingSafeEqual(provided, expected); +} + +// --- Public routes (no auth) --- +app.use('/login.html', express.static(join(__dirname, 'public', 'login.html'))); + +app.get('/auth/setup-check', (_req, res) => { + res.json({ setupMode: isSetupMode(), mailConfigured: isMailerConfigured(), setupTokenRequired: Boolean(SETUP_TOKEN) }); +}); + +// Setup: create first admin with password +app.post('/auth/setup', (req, res) => { + if (!isSetupMode()) return res.status(400).json({ error: 'Setup bereits abgeschlossen' }); + const { email, name, password, setup_token: setupToken } = req.body; + if (!setupTokenMatches(req.get('x-vendoo-setup-token') || setupToken)) return res.status(403).json({ error: 'Ungültiger Einrichtungs-Code.' }); + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + + const ip = getClientIp(req); + try { + const user = createUser(email, name || email.split('@')[0], 'admin', null, password); + auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via setup', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + return res.json({ ok: true, redirect: '/' }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Login with email + password +app.post('/auth/login', (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' }); + + const ip = getClientIp(req); + const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000); + if (!rl.allowed) return res.status(429).json({ error: `Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.` }); + + const user = getUserByEmail(email); + if (user && isUserLocked(user)) { + auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip); + return res.status(423).json({ error: 'Account nach mehreren Fehlversuchen vorübergehend gesperrt.', locked_until: user.locked_until }); + } + if (!user || !verifyPassword(password, user.password_hash)) { + if (user) { + recordLogin(user.id, ip, req.headers['user-agent'] || '', false); + const lockResult = registerFailedLogin(user.id); + if (lockResult?.locked) { + auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip); + return res.status(423).json({ error: 'Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', locked_until: lockResult.lockedUntil }); + } + } + auditLog(user?.id || null, email, 'login.failed', null, null, null, ip); + return res.status(401).json({ error: 'E-Mail oder Passwort falsch' }); + } + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + clearFailedLogins(user.id); + + const session = createSession(user.id, ip, req.headers['user-agent'] || ''); + recordLogin(user.id, ip, req.headers['user-agent'] || '', true); + auditLog(user.id, email, 'login.success', null, null, 'via password', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + res.json({ ok: true, redirect: '/' }); +}); + +// Invite: admin sends invite link (magic link only for invitations) +app.post('/auth/invite', requireAuth, csrfProtect, requireRole('admin'), async (req, res) => { + const { email, name, role } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const baseUrl = `${req.protocol}://${req.get('host')}`; + try { + const { token } = createAuthToken(email, 'invite', role || 'editor', req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${role || 'editor'}`, getClientIp(req)); + res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Magic link: only for invite — shows password-set form +app.get('/auth/magic', (req, res) => { + const { token } = req.query; + if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben')); + res.redirect(`/login.html?invite=${encodeURIComponent(token)}`); +}); + +// Accept invite: set password and activate account +app.post('/auth/accept-invite', (req, res) => { + const { token, password, name } = req.body; + if (!token) return res.status(400).json({ error: 'Kein Token angegeben' }); + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + + try { + const authToken = validateAuthToken(token); + if (!authToken) return res.status(400).json({ error: 'Link ungültig oder abgelaufen' }); + + const ip = getClientIp(req); + const ua = req.headers['user-agent'] || ''; + + let user = getUserByEmail(authToken.email); + if (!user) { + user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password); + auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip); + } else { + setPassword(user.id, password); + if (name) updateUser(user.id, { name }); + } + + if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); + + const session = createSession(user.id, ip, ua); + recordLogin(user.id, ip, ua, true); + auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip); + + const csrf = generateCsrfToken(); + res.cookie('auth_token', session.token, secureCookieOptions(req)); + res.cookie('csrf_token', csrf, csrfCookieOptions(req)); + res.json({ ok: true, redirect: '/' }); + } catch (err) { + console.error('Accept invite error:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/auth/logout', requireAuth, csrfProtect, (req, res) => { + const token = req.cookies?.auth_token; + if (token) { + const session = validateSession(token); + if (session) { + auditLog(session.user_id, session.email, 'logout', null, null, null, getClientIp(req)); + } + destroySession(token); + } + res.clearCookie('auth_token'); + res.clearCookie('csrf_token'); + res.json({ ok: true }); +}); + + +// --- Mobile photo upload (public token page, no account login required) --- +app.get('/mobile-upload/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).sendFile(join(__dirname, 'public', 'mobile-upload-expired.html')); + res.sendFile(join(__dirname, 'public', 'mobile-upload.html')); +}); + +app.get('/mobile-upload-api/:token', (req, res) => { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + res.json({ + id: session.id, + context: session.context, + listing_id: session.listing_id, + files: session.files || [], + remaining: Math.max(0, 10 - (session.files || []).length), + expires_at: session.expires_at, + }); +}); + +// --- Protected routes below --- +app.use('/api', requireAuth); +app.use('/api', apiRateLimit); +app.use('/api', apiPermissionGuard); +app.use('/auth/ebay', requireAuth, requirePermission('settings.manage')); +app.use('/auth/etsy', requireAuth, requirePermission('settings.manage')); + +// CSRF for state-changing API calls +app.use('/api', csrfProtect); + +// Platform kernel contracts (new routes must be deny-by-default and explicitly owned) +platformKernel.routes.register(app, { + id: 'platform.status.read', + method: 'GET', + path: '/api/security/platform', + owner: 'vendoo.system', + policy: 'platform.security-admin', + audit: 'platform.status.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json(platformKernel.snapshot()), +}); + +platformKernel.routes.register(app, { + id: 'platform.modules.read', + method: 'GET', + path: '/api/security/platform/modules', + owner: 'vendoo.system', + policy: 'platform.security-admin', + audit: 'platform.modules.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json({ modules: platformKernel.modules.snapshot() }), +}); + +platformKernel.routes.register(app, { + id: 'themes.contract.read', + method: 'GET', + path: '/api/security/platform/theme-contract', + owner: 'vendoo.themes', + policy: 'platform.security-admin', + audit: 'themes.contract.read', + rateLimit: 'administration', + csrf: false, + stability: 'internal', + handler: async (_req, res) => res.json(getThemeContract()), +}); + +platformKernel.routes.register(app, { + id: 'themes.catalog.read', method: 'GET', path: '/api/themes', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.catalog.read', rateLimit: 'default', csrf: false, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().catalog(req.user.id)), +}); + +platformKernel.routes.register(app, { + id: 'themes.validate', method: 'POST', path: '/api/themes/validate', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.validated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().validate(req.body || {})), +}); + +platformKernel.routes.register(app, { + id: 'themes.preference.update', method: 'PUT', path: '/api/themes/preference', owner: 'vendoo.themes', + policy: 'themes.use', audit: 'themes.preference.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const preference = getThemeService().saveUserPreference(req.user.id, req.body || {}); + auditLog(req.user.id, req.user.email, 'theme.preference.updated', 'user', String(req.user.id), JSON.stringify(preference), getClientIp(req)); + res.json(preference); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.create', method: 'POST', path: '/api/themes', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.created', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const theme = getThemeService().create(req.body || {}, req.user.id); + auditLog(req.user.id, req.user.email, 'theme.created', 'theme', theme.id, theme.name, getClientIp(req)); + res.status(201).json(theme); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.system-default.update', method: 'PUT', path: '/api/themes/system-default', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.system-default.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const preference = getThemeService().saveSystemDefault(req.body || {}); + auditLog(req.user.id, req.user.email, 'theme.system_default.updated', 'system', 'theme', JSON.stringify(preference), getClientIp(req)); + res.json(preference); + }, +}); + + + +platformKernel.routes.register(app, { + id: 'themes.profile.update', method: 'PUT', path: '/api/themes/:id', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.updated', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const theme = getThemeService().update(req.params.id, req.body || {}, req.user.id); + auditLog(req.user.id, req.user.email, 'theme.updated', 'theme', theme.id, theme.name, getClientIp(req)); + res.json(theme); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.delete', method: 'DELETE', path: '/api/themes/:id', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.deleted', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const result = getThemeService().remove(req.params.id); + auditLog(req.user.id, req.user.email, 'theme.deleted', 'theme', req.params.id, null, getClientIp(req)); + res.json(result); + }, +}); + +platformKernel.routes.register(app, { + id: 'themes.profile.export', method: 'GET', path: '/api/themes/:id/export', owner: 'vendoo.themes', + policy: 'themes.manage', audit: 'themes.profile.exported', rateLimit: 'administration', csrf: false, stability: 'stable', + handler: async (req, res) => res.json(getThemeService().exportTheme(req.params.id)), +}); + +// Current user info +app.get('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ setupMode: true, role: 'admin' }); + const user = getUser(req.user.id); + const initials = (user.name || user.email).split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); + res.json({ + id: user.id, email: user.email, name: user.name, role: user.role, + avatar_color: user.avatar_color, initials, permissions: [...permissionsForRole(user.role)], + role_definition: getRoleDefinitions().find(role => role.key === user.role) || null, + csrf: req.cookies?.csrf_token, + }); +}); + +app.put('/api/me', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + const { name } = req.body; + if (name !== undefined) updateUser(req.user.id, { name }); + auditLog(req.user.id, req.user.email, 'user.profile_updated', 'user', String(req.user.id), null, getClientIp(req)); + res.json(getUser(req.user.id)); +}); + +// --- Roles, permissions and server security --- +app.get('/api/security/roles', (_req, res) => { + res.json({ roles: getRoleDefinitions(), permissions: getPermissionCatalog() }); +}); + +app.get('/api/security/status', requirePermission('security.manage'), (req, res) => { + const bindHost = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; + const publicBaseUrl = String(process.env.PUBLIC_BASE_URL || '').trim(); + const remoteBinding = ['0.0.0.0', '::'].includes(bindHost); + const publicHttps = /^https:\/\//i.test(publicBaseUrl); + const warnings = []; + if (remoteBinding && !publicHttps) warnings.push('Vendoo lauscht im Netzwerk, aber PUBLIC_BASE_URL verwendet kein HTTPS. Für öffentlichen Zugriff ist ein HTTPS-Reverse-Proxy zwingend.'); + if (remoteBinding && !String(process.env.VENDOO_ALLOWED_HOSTS || '').trim()) warnings.push('VENDOO_ALLOWED_HOSTS ist nicht gesetzt. Private IP-Adressen bleiben erlaubt, öffentliche Hostnamen nicht.'); + if (TRUST_PROXY && !publicHttps) warnings.push('VENDOO_TRUST_PROXY ist aktiv, obwohl keine öffentliche HTTPS-Basis-URL erkannt wurde.'); + if (String(process.env.VENDOO_ALLOWED_ORIGINS || '').includes('*')) warnings.push('Wildcard-Origin ist unsicher und wird von Vendoo nicht als vertrauenswürdig behandelt.'); + if (process.env.NODE_ENV === 'production' && /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || 'false'))) warnings.push('Alle Browser-Erweiterungs-Origins sind freigegeben. Für Produktion VENDOO_EXTENSION_ORIGINS auf konkrete Extension-IDs begrenzen.'); + res.json({ + version: APP_VERSION, bind_host: bindHost, port: Number(PORT), trust_proxy: TRUST_PROXY, + public_base_url: publicBaseUrl || null, + allowed_hosts: String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(v => v.trim()).filter(Boolean), + allowed_origins: [...configuredAllowedOrigins()], + extension_origins: [...configuredExtensionOrigins()], + session_days: Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)), + session_idle_minutes: Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)), + remote_binding: remoteBinding, secure_public_url: publicHttps, + active_locks: listResourceLocks(), rate_limits: getRateLimitStats(), warnings, + request_ip: getClientIp(req), request_secure: Boolean(req.secure || (TRUST_PROXY && req.get('x-forwarded-proto') === 'https')), + }); +}); + +app.get('/api/locks/:type/:id', (req, res) => { + const lock = getResourceLock(req.params.type, req.params.id); + res.json({ lock, owned_by_me: Boolean(lock && Number(lock.user_id) === Number(req.user.id)) }); +}); + +app.post('/api/locks/:type/:id/acquire', (req, res) => { + try { + const result = acquireResourceLock({ + resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, + clientLabel: req.body?.client_label || req.get('user-agent') || '', ttlSeconds: req.body?.ttl_seconds || 120, + }); + auditLog(req.user.id, req.user.email, 'lock.acquired', req.params.type, req.params.id, null, getClientIp(req)); + res.status(201).json(result); + } catch (error) { + res.status(error.code === 'LOCKED' ? 409 : 400).json({ error: error.message, code: error.code, lock: error.lock || null }); + } +}); + +app.post('/api/locks/:type/:id/renew', (req, res) => { + try { + const lock = renewResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token, ttlSeconds: req.body?.ttl_seconds || 120 }); + res.json({ lock }); + } catch (error) { res.status(409).json({ error: error.message, code: error.code, lock: error.lock || null }); } +}); + +app.delete('/api/locks/:type/:id', (req, res) => { + try { + const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token }); + if (changes) auditLog(req.user.id, req.user.email, 'lock.released', req.params.type, req.params.id, null, getClientIp(req)); + res.json({ ok: true, changes }); + } catch (error) { res.status(409).json({ error: error.message, code: error.code }); } +}); + +app.get('/api/security/locks', requirePermission('security.manage'), (_req, res) => res.json(listResourceLocks())); +app.delete('/api/security/locks/:type/:id', requirePermission('security.manage'), (req, res) => { + const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: '', force: true }); + auditLog(req.user.id, req.user.email, 'lock.force_released', req.params.type, req.params.id, null, getClientIp(req)); + res.json({ ok: true, changes }); +}); + +// --- Admin: User Management --- +app.get('/api/admin/users', requireRole('admin'), (_req, res) => { + res.json(getUsers()); +}); + +app.post('/api/admin/users/invite', requireRole('admin'), async (req, res) => { + const { email, name, role, method } = req.body; + if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); + const userRole = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor'; + + const existing = getUserByEmail(email); + if (existing) return res.status(400).json({ error: 'User existiert bereits' }); + + const baseUrl = `${req.protocol}://${req.get('host')}`; + const ip = getClientIp(req); + + try { + if (method === 'auto_password') { + // Generate random password + const password = `Vd!${randomBytes(9).toString('base64url')}9a`; + const user = createUser(email, name || email.split('@')[0], userRole, req.user.id, password); + const mailResult = await sendCredentials(email, password, name || '', baseUrl); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}, Auto-Passwort`, ip); + res.json({ ok: true, emailSent: mailResult.sent, consoleFallback: mailResult.consoleFallback || false, password }); + } else { + // Magic link (existing behavior) + const { token } = createAuthToken(email, 'invite', userRole, req.user.id); + const result = await sendMagicLink(email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}`, ip); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/admin/users/resend-invite', requireRole('admin'), async (req, res) => { + const { user_id } = req.body; + const user = getUser(user_id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + + const { token } = createAuthToken(user.email, 'invite', user.role, req.user.id); + const baseUrl = `${req.protocol}://${req.get('host')}`; + const result = await sendMagicLink(user.email, token, baseUrl, 'invite'); + auditLog(req.user.id, req.user.email, 'user.invite_resent', 'user', String(user.id), null, getClientIp(req)); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); +}); + +app.put('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + const existingUser = getUser(id); + if (!existingUser) return res.status(404).json({ error: 'User nicht gefunden' }); + if (req.user.id === id && req.body.role && req.body.role !== 'admin') { + return res.status(400).json({ error: 'Du kannst dir selbst nicht die Admin-Rolle entziehen' }); + } + const activeAdmins = getUsers().filter(user => user.role === 'admin' && user.active).length; + const removesLastAdmin = existingUser.role === 'admin' && existingUser.active && activeAdmins <= 1 && (req.body.role && req.body.role !== 'admin' || Number(req.body.active) === 0); + if (removesLastAdmin) return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht deaktiviert oder herabgestuft werden.' }); + const updated = updateUser(id, req.body); + if (!updated) return res.status(404).json({ error: 'User nicht gefunden' }); + auditLog(req.user.id, req.user.email, 'user.updated', 'user', String(id), JSON.stringify(req.body), getClientIp(req)); + res.json(updated); +}); + +app.delete('/api/admin/users/:id', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + if (req.user.id === id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + if (user.role === 'admin' && user.active && getUsers().filter(entry => entry.role === 'admin' && entry.active).length <= 1) { + return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht gelöscht werden.' }); + } + deleteUser(id); + auditLog(req.user.id, req.user.email, 'user.deleted', 'user', String(id), user.email, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Sessions --- +app.get('/api/admin/sessions', requireRole('admin'), (_req, res) => { + res.json(getAllActiveSessions()); +}); + +app.delete('/api/admin/sessions/:id', requireRole('admin'), (req, res) => { + destroySessionById(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/admin/users/:id/sessions', requireRole('admin'), (req, res) => { + destroyUserSessions(parseInt(req.params.id)); + auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +// --- Admin: Audit Log --- +app.get('/api/admin/audit', requireRole('admin', 'manager'), (req, res) => { + const result = searchAuditLog({ + limit: req.query.limit, offset: req.query.offset, userId: req.query.user_id, + action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q, + }); + res.json(result); +}); + +app.get('/api/admin/audit/export.csv', requireRole('admin', 'manager'), (req, res) => { + const result = searchAuditLog({ limit: 1000, userId: req.query.user_id, action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q }); + const csvEscape = value => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = [['Zeit', 'Benutzer', 'Aktion', 'Zieltyp', 'Ziel-ID', 'Details', 'IP'].map(csvEscape).join(';')]; + for (const row of result.rows) lines.push([row.created_at, row.user_email, row.action, row.target_type, row.target_id, row.details, row.ip].map(csvEscape).join(';')); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="vendoo-audit-${new Date().toISOString().slice(0, 10)}.csv"`); + res.send('\uFEFF' + lines.join('\n')); +}); + +// --- Admin: Login History --- +app.get('/api/admin/login-history/:userId', requireRole('admin'), (req, res) => { + res.json(getLoginHistory(parseInt(req.params.userId))); +}); + +// --- Admin: SMTP Settings --- +app.get('/api/admin/smtp', requireRole('admin'), (_req, res) => { + const settings = getSettings(); + res.json({ + smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', + smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587', + smtp_user: settings.smtp_user || process.env.SMTP_USER || '', + smtp_pass: (settings.smtp_pass || process.env.SMTP_PASS) ? '••••••••' : '', + smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', + configured: isMailerConfigured(), + source: settings.smtp_host ? 'db' : (process.env.SMTP_HOST ? 'env' : 'none'), + }); +}); + +app.put('/api/admin/smtp', requireRole('admin'), (req, res) => { + const { smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from } = req.body; + const smtpSettings = {}; + if (smtp_host !== undefined) smtpSettings.smtp_host = smtp_host; + if (smtp_port !== undefined) smtpSettings.smtp_port = smtp_port; + if (smtp_user !== undefined) smtpSettings.smtp_user = smtp_user; + if (smtp_pass !== undefined && smtp_pass !== '••••••••') smtpSettings.smtp_pass = smtp_pass; + if (smtp_from !== undefined) smtpSettings.smtp_from = smtp_from; + updateSettings(smtpSettings); + + // Reinitialize mailer with new config + const allSettings = getSettings(); + if (allSettings.smtp_host && allSettings.smtp_user && allSettings.smtp_pass) { + const ok = reinitMailer({ + host: allSettings.smtp_host, + port: allSettings.smtp_port || '587', + user: allSettings.smtp_user, + pass: allSettings.smtp_pass, + }); + if (allSettings.smtp_from) process.env.SMTP_FROM = allSettings.smtp_from; + } + + if (req.user) auditLog(req.user.id, req.user.email, 'smtp.updated', null, null, null, getClientIp(req)); + res.json({ + smtp_host: allSettings.smtp_host || '', + smtp_port: allSettings.smtp_port || '587', + smtp_user: allSettings.smtp_user || '', + smtp_pass: allSettings.smtp_pass ? '••••••••' : '', + smtp_from: allSettings.smtp_from || '', + configured: isMailerConfigured(), + }); +}); + +app.post('/api/admin/smtp/test', requireRole('admin'), async (req, res) => { + try { + if (!isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' }); + const adminEmail = req.user?.email; + if (!adminEmail) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' }); + await testMailer(adminEmail); + auditLog(req.user.id, req.user.email, 'smtp.test_sent', null, null, `an ${adminEmail}`, getClientIp(req)); + res.json({ ok: true, message: `Testmail an ${adminEmail} gesendet` }); + } catch (err) { + res.status(500).json({ error: `SMTP-Fehler: ${err.message}` }); + } +}); + +// --- Admin: Password Reset --- +app.post('/api/admin/users/:id/reset-password', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + const { password } = req.body; + const passwordCheck = validatePasswordStrength(password); + if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); + const user = getUser(id); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + setPassword(id, password); + auditLog(req.user.id, req.user.email, 'user.password_reset', 'user', String(id), `für ${user.email}`, getClientIp(req)); + res.json({ ok: true, message: `Passwort für ${user.email} zurückgesetzt` }); +}); + +// --- Admin: User Login History --- +app.get('/api/admin/users/:id/login-history', requireRole('admin'), (req, res) => { + const id = parseInt(req.params.id); + res.json(getLoginHistory(id, 30)); +}); + +// --- Admin: System Diagnostics --- +function diagnosticItem(id, label, status, detail, meta = {}) { + return { id, label, status, detail, ...meta }; +} + +async function probeComfyUi(baseUrl) { + const normalized = String(baseUrl || 'http://127.0.0.1:8188').replace(/\/$/, ''); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1800); + try { + const response = await fetch(`${normalized}/system_stats`, { signal: controller.signal }); + if (!response.ok) return { ok: false, detail: `HTTP ${response.status}` }; + const payload = await response.json().catch(() => ({})); + const device = payload?.devices?.[0]; + const deviceName = device?.name || device?.type || 'ComfyUI'; + return { ok: true, detail: `Erreichbar · ${deviceName}` }; + } catch (error) { + return { ok: false, detail: error?.name === 'AbortError' ? 'Zeitüberschreitung' : (error?.message || 'Nicht erreichbar') }; + } finally { + clearTimeout(timer); + } +} + +app.get('/api/admin/diagnostics', requireRole('admin'), async (_req, res) => { + const startedAt = Date.now(); + const checks = []; + const root = __dirname; + const settings = getSettings(); + + try { + const result = db.pragma('quick_check', { simple: true }); + checks.push(diagnosticItem('database', 'Datenbankintegrität', result === 'ok' ? 'ok' : 'error', result === 'ok' ? 'SQLite quick_check: ok' : String(result))); + } catch (error) { + checks.push(diagnosticItem('database', 'Datenbankintegrität', 'error', error.message)); + } + + const writableTargets = [ + ['uploads', 'Upload-Verzeichnis', UPLOADS_DIR], + ['db-write', 'Datenbank-Verzeichnis', dirname(DB_PATH)], + ]; + for (const [id, label, target] of writableTargets) { + try { + mkdirSync(target, { recursive: true }); + const probe = join(target, `.vendoo-write-test-${process.pid}-${Date.now()}.tmp`); + writeFileSync(probe, 'ok'); + unlinkSync(probe); + checks.push(diagnosticItem(id, label, 'ok', 'Schreibzugriff vorhanden')); + } catch (error) { + checks.push(diagnosticItem(id, label, 'error', error.message)); + } + } + + const requiredAssets = [ + ['ui-html', 'UI-Dokument', join(root, 'public', 'index.html')], + ['ui-js', 'UI-Logik', join(root, 'public', 'app.js')], + ['ui-css', 'UI-Styles', join(root, 'public', 'style.css')], + ['flux-workflow', 'FLUX-Workflow', join(root, String(settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'))], + ]; + for (const [id, label, target] of requiredAssets) { + const present = existsSync(target); + checks.push(diagnosticItem(id, label, present ? 'ok' : 'error', present ? basename(target) : `Fehlt: ${target}`)); + } + + const extensionTargets = [ + ['Chrome', join(root, 'extensions', 'chrome', 'manifest.json')], + ['Edge', join(root, 'extensions', 'edge', 'manifest.json')], + ['Firefox', join(root, 'extensions', 'firefox', 'manifest.json')], + ['Safari', join(root, 'extensions', 'safari', 'web-extension', 'manifest.json')], + ]; + const extensionStates = extensionTargets.map(([name, target]) => ({ name, present: existsSync(target) })); + const extensionMissing = extensionStates.filter(item => !item.present).map(item => item.name); + checks.push(diagnosticItem('extensions', 'Browser-Extensions', extensionMissing.length ? 'warning' : 'ok', extensionMissing.length ? `Fehlend: ${extensionMissing.join(', ')}` : 'Chrome, Edge, Firefox und Safari vorhanden')); + + const configuredProviders = getProviders().map(provider => { + const configured = !provider.needsKey || Boolean(process.env[provider.needsKey]); + return { id: provider.id, configured }; + }); + const activeProvider = String(settings.ai_provider || settings.default_ai_provider || 'nicht festgelegt'); + checks.push(diagnosticItem('ai-provider', 'AI-Konfiguration', configuredProviders.some(item => item.configured) ? 'ok' : 'warning', `Aktiv: ${activeProvider} · verfügbar: ${configuredProviders.filter(item => item.configured).map(item => item.id).join(', ') || 'keiner'}`)); + + const comfyUrl = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + const comfy = await probeComfyUi(comfyUrl); + checks.push(diagnosticItem('comfyui', 'ComfyUI / FLUX', comfy.ok ? 'ok' : 'optional', `${comfy.detail} · ${comfyUrl}`)); + + const counts = checks.reduce((acc, check) => { acc[check.status] = (acc[check.status] || 0) + 1; return acc; }, {}); + const hardFailures = checks.filter(check => check.status === 'error').length; + res.setHeader('Cache-Control', 'no-store'); + res.json({ + ok: hardFailures === 0, + version: APP_VERSION, + generated_at: new Date().toISOString(), + duration_ms: Date.now() - startedAt, + summary: counts, + checks, + runtime: { node: process.version, platform: process.platform, arch: process.arch, uptime_seconds: Math.floor(process.uptime()), memory_mb: Math.round(process.memoryUsage().rss / 1048576) }, + }); +}); + +// --- Admin: System Info --- +app.get('/api/admin/system', requireRole('admin'), (_req, res) => { + const users = getUsers(); + const listings = getListings(); + const sessions = getAllActiveSessions(); + + let dbSize = 'unbekannt'; + try { + const dbPath = DB_PATH; + if (existsSync(dbPath)) { + const bytes = statSync(dbPath).size; + if (bytes < 1024) dbSize = bytes + ' B'; + else if (bytes < 1024 * 1024) dbSize = (bytes / 1024).toFixed(1) + ' KB'; + else dbSize = (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + } catch {} + + const uptimeSeconds = process.uptime(); + const hours = Math.floor(uptimeSeconds / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + + res.json({ + node_version: process.version, + uptime, + uptime_seconds: Math.floor(uptimeSeconds), + total_users: users.length, + total_listings: listings.length, + active_sessions: sessions.length, + db_size: dbSize, + platform: process.platform, + memory_mb: Math.round(process.memoryUsage().rss / (1024 * 1024)), + }); +}); + +// --- Admin: Session Cleanup --- +app.post('/api/admin/sessions/cleanup', requireRole('admin'), (req, res) => { + try { + cleanExpiredSessions(); + auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, getClientIp(req)); + res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' }); + } catch (err) { + res.json({ ok: false, error: err.message }); + } +}); + +// --- My Sessions --- +app.get('/api/my/sessions', (req, res) => { + if (isSetupMode()) return res.json([]); + res.json(getUserSessions(req.user.id)); +}); + +app.delete('/api/my/sessions/:id', (req, res) => { + if (isSetupMode()) return res.json({ ok: true }); + destroySessionById(parseInt(req.params.id)); + res.json({ ok: true }); +}); + +// Static files served for everyone — auth check happens via /api/me in frontend +app.use((req, res, next) => { + const path = String(req.path || '').toLowerCase(); + if (path === '/' || path.endsWith('.html') || path.endsWith('.js') || path.endsWith('.css')) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); + } + next(); +}); +app.use(express.static(join(__dirname, 'public'), { etag: true, maxAge: 0 })); +app.use('/uploads', requireAuth, express.static(UPLOADS_DIR, { etag: true, maxAge: '1h', fallthrough: false })); + +function sanitizeUploadPlatform(value) { + const clean = String(value || 'allgemein').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + return clean.slice(0, 48) || 'allgemein'; +} + +const storage = multer.diskStorage({ + destination: (req, _file, cb) => { + const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); + const dir = join(UPLOADS_DIR, platform); + mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + cb(null, `${randomUUID()}${ext}`); + }, +}); +const upload = multer({ + storage, + fileFilter: (_req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']; + const ext = extname(file.originalname).toLowerCase(); + const isImageMime = String(file.mimetype || '').startsWith('image/'); + cb(null, allowed.includes(ext) && isImageMime); + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}); + + +const operationsUpload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => { const dir = join(OPERATIONS_DIR, 'staging'); mkdirSync(dir, { recursive: true }); cb(null, dir); }, + filename: (_req, file, cb) => cb(null, `${Date.now()}-${randomUUID()}${extname(file.originalname || '').toLowerCase() || '.bin'}`), + }), + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname || '').toLowerCase(); + cb(null, ['.zip', '.db', '.sqlite', '.sqlite3'].includes(ext)); + }, + limits: { fileSize: 2 * 1024 * 1024 * 1024 }, +}); + + + +async function normalizeMobileUploadFile(file) { + const ext = extname(file.originalname || file.filename || '').toLowerCase(); + if (!['.heic', '.heif'].includes(ext) && !/hei[cf]/i.test(file.mimetype || '')) { + return file; + } + + const outputName = `${randomUUID()}.jpg`; + const outputPath = join(dirname(file.path), outputName); + try { + await sharp(file.path).rotate().jpeg({ quality: 90 }).toFile(outputPath); + try { unlinkSync(file.path); } catch {} + return { + ...file, + filename: outputName, + path: outputPath, + mimetype: 'image/jpeg', + originalname: String(file.originalname || 'foto.heic').replace(/\.(heic|heif)$/i, '.jpg'), + }; + } catch (error) { + try { unlinkSync(file.path); } catch {} + throw new Error('HEIC/HEIF konnte auf diesem System nicht in JPG umgewandelt werden. Bitte am Smartphone „Am kompatibelsten“ wählen oder als JPG/PNG hochladen.'); + } +} + + +function validateMobileUploadToken(req, res, next) { + const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); + if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); + if ((session.files || []).length >= 10) return res.status(400).json({ error: 'Maximal 10 Bilder pro Upload-Sitzung.' }); + req.mobileUploadSession = session; + next(); +} + +app.post('/mobile-upload-api/:token/photos', validateMobileUploadToken, upload.array('photos', 10), async (req, res) => { + const session = req.mobileUploadSession; + const remaining = Math.max(0, 10 - (session.files || []).length); + const uploadedFiles = req.files || []; + const acceptedFiles = uploadedFiles.slice(0, remaining); + for (const extraFile of uploadedFiles.slice(remaining)) { + try { unlinkSync(extraFile.path); } catch {} + } + + try { + const normalizedFiles = []; + for (const file of acceptedFiles) normalizedFiles.push(await normalizeMobileUploadFile(file)); + const platform = req.body?.platform || req.query?.platform || 'allgemein'; + const filenames = normalizedFiles.map(file => `${platform}/${file.filename}`); + const updated = appendMobileUploadFiles(session.id, filenames); + res.json({ + ok: true, + files: updated?.files || [], + added: filenames.length, + remaining: Math.max(0, 10 - (updated?.files || []).length), + }); + } catch (error) { + for (const file of acceptedFiles) { + try { if (existsSync(file.path)) unlinkSync(file.path); } catch {} + } + res.status(400).json({ error: error.message || 'Bilder konnten nicht verarbeitet werden.' }); + } +}); + +// --- Platforms --- +const platforms = {}; +async function loadPlatforms() { + for (const f of ['vinted', 'ebay-ka', 'ebay-de', 'etsy']) { + const mod = await import(`./platforms/${f}.mjs`); + platforms[mod.default.id] = mod.default; + } +} + +// --- Upload --- +app.post('/api/upload', upload.array('photos', 10), (req, res) => { + const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); + res.json({ filenames: req.files.map(f => `${platform}/${f.filename}`) }); +}); + +app.get('/api/image-editor/versions', async (req, res) => { + try { + const path = String(req.query?.path || '').trim(); + const resolved = resolveEditableImage(UPLOADS_DIR, path); + const history = getImageEditVersions(resolved.normalized, req.query?.limit || 50); + const original = resolveEditableImage(UPLOADS_DIR, history.root_path || resolved.normalized); + const stats = statSync(original.absolute); + const metadata = await sharp(original.absolute, { failOn: 'none' }).metadata(); + res.json({ + root_path: history.root_path || resolved.normalized, + current_path: resolved.normalized, + original: { + id: 'original', + output_path: history.root_path || resolved.normalized, + root_path: history.root_path || resolved.normalized, + source_path: null, + context: 'original', + operations: {}, + width: metadata.width || null, + height: metadata.height || null, + format: metadata.format || null, + file_size: stats.size, + created_at: stats.birthtime?.toISOString?.() || stats.mtime?.toISOString?.() || null, + }, + versions: history.versions, + }); + } catch (error) { + res.status(400).json({ error: error.message || 'Bildversionen konnten nicht geladen werden.' }); + } +}); + +app.post('/api/image-editor/render', async (req, res) => { + try { + const result = await renderImageVersion({ + uploadRoot: UPLOADS_DIR, + sourcePath: req.body?.source_path, + operations: req.body?.operations, + context: req.body?.context || 'generator', + userId: req.user?.id || null, + rootPath: getImageEditRoot(String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()) || String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(), + recordVersion: createImageEditVersion, + }); + const history = getImageEditVersions(result.filename, 50); + res.status(201).json({ ...result, versions: history.versions }); + } catch (error) { + res.status(400).json({ error: error.message || 'Bildversion konnte nicht erstellt werden.' }); + } +}); + + +app.get('/api/network-info', (_req, res) => { + const addresses = getLanIpv4Addresses(); + res.json({ + port: Number(PORT), + addresses, + preferred_url: addresses[0]?.address ? `http://${addresses[0].address}:${PORT}` : `http://localhost:${PORT}`, + public_base_url: String(process.env.PUBLIC_BASE_URL || '').trim() || null, + runtime_config_path: CONFIG_PATH, + }); +}); + +app.post('/api/mobile-upload-sessions', (req, res) => { + cleanupMobileUploadSessions(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashMobileUploadToken(token); + const context = ['generator', 'listing'].includes(req.body?.context) ? req.body.context : 'generator'; + const listingId = req.body?.listing_id ? Number(req.body.listing_id) : null; + if (context === 'listing' && listingId && !getListing(listingId)) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + const expires = new Date(Date.now() + 20 * 60 * 1000); + const expiresSql = expires.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + const session = createMobileUploadSession({ + token_hash: tokenHash, + user_id: req.user?.id || null, + listing_id: listingId, + context, + expires_at: expiresSql, + }); + const baseUrl = resolvePublicBaseUrl(req); + const url = `${baseUrl}/mobile-upload/${encodeURIComponent(token)}`; + res.json({ + id: session.id, + url, + base_url: baseUrl, + detected_lan_ip: getLanIpv4Addresses()[0]?.address || null, + token, + context, + listing_id: listingId, + files: [], + expires_at: expires.toISOString(), + }); +}); + +app.get('/api/mobile-upload-sessions/:id', (req, res) => { + cleanupMobileUploadSessions(); + const session = getMobileUploadSessionById(Number(req.params.id), req.user?.id || null); + if (!session || !isMobileUploadSessionUsable(session)) return res.status(404).json({ error: 'Upload-Sitzung nicht gefunden oder abgelaufen' }); + res.json({ id: session.id, context: session.context, listing_id: session.listing_id, files: session.files || [], expires_at: session.expires_at, status: session.status }); +}); + +app.delete('/api/mobile-upload-sessions/:id', (req, res) => { + deleteMobileUploadSession(Number(req.params.id), req.user?.id || null); + res.json({ ok: true }); +}); + +// --- Image tools --- +app.post('/api/images/remove-bg', async (req, res) => { + try { + const { filename } = req.body; + const newName = await removeBackground(filename); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/images/watermark', async (req, res) => { + try { + const { filename, text } = req.body; + const newName = await addWatermark(filename, text); + res.json({ filename: newName }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- AI Model Photos --- +app.get('/api/listings/:id/ai-model-generations', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const generations = getAiModelGenerationsForListing(req.params.id).map(item => ({ + ...item, + photos: getAiModelPhotosForGeneration(item.id), + })); + res.json(generations); +}); + +app.get('/api/ai-model-photos/jobs/:id', (req, res) => { + const job = getAiModelJobWithRelations(req.params.id); + if (!job) return res.status(404).json({ error: 'Nicht gefunden' }); + const response = { ...job }; + if (job.generation_id) { + response.photos = getAiModelPhotosForGeneration(job.generation_id); + } + res.json(response); +}); + +app.post('/api/ai-model-photos/jobs', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const precheck = await precheckAiModelPhotos(payload, settings); + if (!precheck.allowed) return res.status(400).json(precheck); + const job = enqueueAiModelJob({ ...payload, settings, preset: precheck.preset, variants: precheck.variants, mode: precheck.mode }); + res.json({ queued: true, job_id: job.id, job, precheck }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/diagnostics', async (_req, res) => { + try { + const diagnostics = await getOpenRouterImageDiagnostics(); + res.json({ + ...diagnostics, + has_api_key: !!process.env.OPENROUTER_API_KEY, + free_only_enabled: String(getSettings().ai_model_photo_openrouter_free_only || '1') !== '0', + }); + } catch (err) { + res.status(500).json({ error: err.message, has_api_key: !!process.env.OPENROUTER_API_KEY }); + } +}); + +app.get('/api/ai-model-photos/providers/openrouter/models', async (req, res) => { + try { + const settings = getSettings(); + const freeOnly = req.query.free_only !== undefined + ? String(req.query.free_only) !== '0' + : String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; + const models = await fetchOpenRouterImageModels({ freeOnly }); + res.json({ provider: 'openrouter', free_only: freeOnly, models }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +function getLocalFluxFileInfo(pathname) { + try { + if (!existsSync(pathname)) return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; + const stats = statSync(pathname); + return { + exists: true, + size_bytes: stats.size, + size_mb: Math.round((stats.size / 1024 / 1024) * 10) / 10, + size_gb: Math.round((stats.size / 1024 / 1024 / 1024) * 100) / 100, + modified_at: stats.mtime?.toISOString?.() || null, + }; + } catch { + return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; + } +} + +function getLocalFluxDirectoryMetrics(rootPath) { + const result = { exists: false, file_count: 0, size_bytes: 0, size_mb: 0 }; + if (!existsSync(rootPath)) return result; + result.exists = true; + const stack = [rootPath]; + while (stack.length) { + const current = stack.pop(); + let entries = []; + try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile()) { + result.file_count += 1; + try { result.size_bytes += statSync(full).size; } catch {} + } + } + } + result.size_mb = Math.round((result.size_bytes / 1024 / 1024) * 10) / 10; + return result; +} + +function stripAnsiSequences(value = '') { + return String(value || '').replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function readLocalFluxLogTail(logPath, maxLines = 40) { + try { + if (!existsSync(logPath)) return []; + return readFileSync(logPath, 'utf-8') + .split(/\r?\n/) + .map(stripAnsiSequences) + .filter(Boolean) + .slice(-maxLines); + } catch { + return []; + } +} + +function readLocalFluxRuntimeState(includeLogs = false) { + const logDir = LOG_DIR; + const statePath = join(logDir, 'local-flux-runtime-state.json'); + const stdoutPath = join(logDir, 'local-flux-runtime.log'); + const stderrPath = join(logDir, 'local-flux-runtime-error.log'); + let state = { + status: 'stopped', + message: 'FLUX / ComfyUI ist nicht gestartet.', + pid: null, + elapsed_seconds: 0, + updated_at: null, + error: '', + }; + if (existsSync(statePath)) { + try { + state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; + } catch { + state = { ...state, status: 'unknown', message: 'FLUX-Runtime-Status konnte nicht gelesen werden.' }; + } + } + const updatedAt = Date.parse(state.updated_at || '') || 0; + const ageSeconds = updatedAt ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : null; + const stale = state.status === 'starting' && ageSeconds !== null && ageSeconds > 240; + if (stale) { + state = { + ...state, + status: 'failed', + message: `FLUX-Startstatus ist seit ${ageSeconds} Sekunden veraltet.`, + error: state.error || 'Der Startprozess liefert keine Statusaktualisierung mehr.', + }; + } + return { + ...state, + age_seconds: ageSeconds, + stale, + state_path: statePath, + stdout_log: stdoutPath, + stderr_log: stderrPath, + ...(includeLogs ? { + stdout_tail: readLocalFluxLogTail(stdoutPath, 30), + stderr_tail: readLocalFluxLogTail(stderrPath, 30), + } : {}), + }; +} + +function findLatestLocalFluxArchive(downloadDir) { + try { + const items = readdirSync(downloadDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && /\.7z$/i.test(entry.name)) + .map(entry => ({ name: entry.name, path: join(downloadDir, entry.name), info: getLocalFluxFileInfo(join(downloadDir, entry.name)) })) + .sort((a, b) => String(b.info.modified_at || '').localeCompare(String(a.info.modified_at || ''))); + return items[0] || null; + } catch { + return null; + } +} + +function readLocalFluxInstallState(includeLog = false) { + const localRoot = join(__dirname, 'local-ai'); + const statePath = join(localRoot, 'install-state.json'); + const logPath = join(LOG_DIR, 'local-flux-install.log'); + const portableRoot = join(localRoot, 'comfyui', 'ComfyUI_windows_portable'); + const comfyMain = join(portableRoot, 'ComfyUI', 'main.py'); + const embeddedPython = join(portableRoot, 'python_embeded', 'python.exe'); + const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'); + const workflowPath = join(localRoot, 'workflows', 'vendoo_flux_schnell_api.json'); + const startScript = join(localRoot, 'start-local-flux.ps1'); + const stopScript = join(localRoot, 'stop-local-flux.ps1'); + const downloadDir = join(localRoot, 'downloads'); + const extractTemp = join(localRoot, 'extract-temp'); + const sevenZipOutputPath = join(LOG_DIR, 'local-flux-7zip-output.log'); + const sevenZipErrorPath = join(LOG_DIR, 'local-flux-7zip-error.log'); + const latestArchive = findLatestLocalFluxArchive(downloadDir); + const partialFiles = (() => { + try { + return readdirSync(downloadDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && /\.part$/i.test(entry.name)) + .map(entry => ({ name: entry.name, ...getLocalFluxFileInfo(join(downloadDir, entry.name)) })); + } catch { return []; } + })(); + + let state = { + status: 'not-started', + phase: 'idle', + progress: 0, + message: 'Lokale FLUX-Installation wurde noch nicht gestartet.', + }; + if (existsSync(statePath)) { + try { + state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; + } catch { + state = { ...state, status: 'unknown', phase: 'unknown', message: 'Installationsstatus konnte nicht gelesen werden.' }; + } + } + + const known7ZipPaths = [ + process.env.ProgramFiles ? join(process.env.ProgramFiles, '7-Zip', '7z.exe') : null, + process.env['ProgramFiles(x86)'] ? join(process.env['ProgramFiles(x86)'], '7-Zip', '7z.exe') : null, + process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', '7-Zip', '7z.exe') : null, + ].filter(Boolean); + const sevenZipPath = known7ZipPaths.find(pathname => existsSync(pathname)) || null; + + const checks = { + seven_zip: { ok: !!sevenZipPath, path: sevenZipPath }, + archive: { ok: !!latestArchive?.info?.exists, name: latestArchive?.name || null, ...(latestArchive?.info || {}) }, + embedded_python: { ok: existsSync(embeddedPython), path: embeddedPython }, + comfyui: { ok: existsSync(comfyMain), path: comfyMain }, + model: { ok: existsSync(modelPath) && getLocalFluxFileInfo(modelPath).size_bytes >= 1024 * 1024 * 1024, path: modelPath, ...getLocalFluxFileInfo(modelPath) }, + workflow: { ok: existsSync(workflowPath), path: workflowPath }, + start_script: { ok: existsSync(startScript), path: startScript }, + stop_script: { ok: existsSync(stopScript), path: stopScript }, + }; + + const completedChecks = Object.values(checks).filter(check => check.ok).length; + const totalChecks = Object.keys(checks).length; + const readiness = Math.round((completedChecks / totalChecks) * 100); + const phaseLabels = { + idle: 'Noch nicht gestartet', + preflight: 'Vorprüfung', + 'download-comfyui': 'ComfyUI wird heruntergeladen', + 'dependency-7zip': '7-Zip wird geprüft', + 'install-7zip': '7-Zip wird installiert', + 'extract-comfyui': 'ComfyUI wird entpackt', + 'install-comfyui': 'ComfyUI-Struktur wird installiert', + configure: 'Lokale Konfiguration wird erstellt', + 'download-model': 'FLUX-Modell wird heruntergeladen', + autostart: 'Autostart wird eingerichtet', + start: 'Lokaler Bildserver wird gestartet', + update: 'ComfyUI wird aktualisiert', + done: 'Installation abgeschlossen', + error: 'Installation fehlgeschlagen', + uninstalled: 'Nicht installiert', + }; + + const extractMetrics = getLocalFluxDirectoryMetrics(extractTemp); + const now = Date.now(); + const heartbeatAt = Date.parse(state.heartbeat_at || state.updated_at || '') || 0; + const heartbeatAgeSeconds = heartbeatAt ? Math.max(0, Math.round((now - heartbeatAt) / 1000)) : null; + const staleThresholdSeconds = state.phase === 'extract-comfyui' ? 15 : 120; + const stalled = state.status === 'running' && heartbeatAgeSeconds !== null && heartbeatAgeSeconds > staleThresholdSeconds; + const installationComplete = checks.embedded_python.ok && checks.comfyui.ok && checks.model.ok && checks.workflow.ok && checks.start_script.ok && checks.stop_script.ok; + let effectiveStatus = stalled ? 'stalled' : state.status; + let effectivePhase = state.phase; + let effectiveMessage = stalled + ? `Keine Statusaktualisierung seit ${heartbeatAgeSeconds} Sekunden. Der Installer oder 7-Zip könnte hängen.` + : state.message; + + // Der reale Dateistand hat Vorrang vor einer alten, beschädigten oder fehlenden Statusdatei. + if (installationComplete && effectiveStatus !== 'running') { + effectiveStatus = 'completed'; + effectivePhase = 'done'; + effectiveMessage = 'Lokales FLUX ist vollständig installiert.'; + } else if (!checks.comfyui.ok && ['completed', 'unknown'].includes(effectiveStatus)) { + effectiveStatus = 'not-started'; + effectivePhase = 'idle'; + effectiveMessage = 'Lokales FLUX ist noch nicht vollständig installiert.'; + } + + const result = { + ...state, + status: effectiveStatus, + original_status: state.status, + phase: effectivePhase, + message: effectiveMessage, + phase_label: stalled ? 'Keine Statusaktualisierung' : (phaseLabels[effectivePhase] || effectivePhase || 'Unbekannt'), + checks, + readiness, + heartbeat_age_seconds: heartbeatAgeSeconds, + stale_threshold_seconds: staleThresholdSeconds, + stalled, + extract_metrics: { + file_count: Number(state.extract_files ?? extractMetrics.file_count ?? 0), + size_mb: Number(state.extract_mb ?? extractMetrics.size_mb ?? 0), + elapsed_seconds: Number(state.extract_elapsed_seconds ?? 0), + process_id: state.extract_process_id || null, + root: extractTemp, + }, + installer_process_id: state.process_id || null, + completed_checks: completedChecks, + total_checks: totalChecks, + installation_complete: installationComplete, + can_resume: ['failed', 'stalled'].includes(effectiveStatus) || ((checks.archive.ok || checks.comfyui.ok || checks.embedded_python.ok) && (!checks.comfyui.ok || !checks.model.ok)), + partial_downloads: partialFiles, + paths: { + state_file: statePath, + log_file: logPath, + portable_root: portableRoot, + model_path: modelPath, + workflow_path: workflowPath, + extract_root: extractTemp, + seven_zip_output: sevenZipOutputPath, + seven_zip_error: sevenZipErrorPath, + }, + }; + if (includeLog) { + result.log_tail = readLocalFluxLogTail(logPath, 80); + result.seven_zip_output_tail = readLocalFluxLogTail(sevenZipOutputPath, 30); + result.seven_zip_error_tail = readLocalFluxLogTail(sevenZipErrorPath, 30); + } + return result; +} + +function spawnLocalFluxPowerShell(scriptName, args = []) { + if (process.platform !== 'win32') { + throw new Error('Die automatische lokale FLUX-Installation ist aktuell für Windows vorgesehen.'); + } + const scriptPath = join(__dirname, 'scripts', scriptName); + if (!existsSync(scriptPath)) throw new Error(`Skript fehlt: ${scriptName}`); + const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args], { + cwd: __dirname, + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + return child.pid; +} + +app.get('/api/local-image/status', async (_req, res) => { + try { + const settings = getSettings(); + const runtime = await getLocalImageStatus(settings); + const install = readLocalFluxInstallState(); + const runtimeState = readLocalFluxRuntimeState(true); + const engineState = !runtime.enabled + ? 'disabled' + : runtime.reachable + ? 'running' + : runtimeState.status === 'starting' + ? 'starting' + : runtimeState.status === 'failed' + ? 'failed' + : install.installation_complete + ? 'stopped' + : 'not-installed'; + res.json({ + ...runtime, + install, + runtime_state: runtimeState, + engine_state: engineState, + engine_label: engineState === 'running' + ? 'Aktiv und bereit' + : engineState === 'starting' + ? 'Wird gestartet' + : engineState === 'failed' + ? 'Start fehlgeschlagen' + : engineState === 'stopped' + ? 'Installiert, aber gestoppt' + : engineState === 'disabled' + ? 'Deaktiviert' + : 'Nicht installiert', + }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler Bildstatus konnte nicht geprüft werden.' }); + } +}); + +app.get('/api/local-image/install-status', requireRole('admin'), (_req, res) => { + res.json(readLocalFluxInstallState(true)); +}); + +app.post('/api/local-image/install', requireRole('admin'), (req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Eine lokale FLUX-Installation läuft bereits.', install: current }); + } + const args = ['-NonInteractive']; + if (req.body?.install_model !== false) args.push('-InstallModel'); + if (req.body?.enable_autostart !== false) args.push('-EnableAutostart'); + if (req.body?.start_now !== false) args.push('-StartNow'); + if (req.body?.force === true) args.push('-Force'); + const gpu = String(req.body?.gpu || 'auto').toLowerCase(); + if (['auto','nvidia','amd','intel','cpu'].includes(gpu)) args.push('-Gpu', gpu); + const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', args); + res.json({ ok: true, pid, message: 'Lokale FLUX-Installation wurde gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokale FLUX-Installation konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/update', requireRole('admin'), (_req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Eine lokale FLUX-Aktion läuft bereits.', install: current }); + } + const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', ['-NonInteractive', '-UpdateOnly', '-StartNow']); + res.json({ ok: true, pid, message: 'Lokales FLUX-Update wurde gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokales FLUX-Update konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/start', requireRole('admin'), (req, res) => { + try { + const settings = getSettings(); + const requested = String(req.body?.profile || settings.flux_runtime_profile || 'auto').toLowerCase(); + const profile = ['auto','fast','balanced','lowvram'].includes(requested) ? requested : 'auto'; + const pid = spawnLocalFluxPowerShell('../local-ai/start-local-flux.ps1', ['-Profile', profile]); + res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird gestartet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht gestartet werden.' }); + } +}); + +app.post('/api/local-image/stop', requireRole('admin'), (_req, res) => { + try { + const pid = spawnLocalFluxPowerShell('../local-ai/stop-local-flux.ps1', []); + res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird beendet.' }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht beendet werden.' }); + } +}); + + +app.post('/api/local-image/uninstall', requireRole('admin'), (req, res) => { + try { + const current = readLocalFluxInstallState(); + if (current.status === 'running') { + return res.status(409).json({ error: 'Während einer laufenden Installation kann FLUX nicht deinstalliert werden.', install: current }); + } + const args = ['-NonInteractive']; + if (req.body?.remove_downloads === true) args.push('-RemoveDownloads'); + const pid = spawnLocalFluxPowerShell('uninstall-local-flux.ps1', args); + + updateSettings({ ai_model_photo_local_enabled: '0' }); + process.env.LOCAL_IMAGE_ENABLED = 'false'; + updateRuntimeConfig({ LOCAL_IMAGE_ENABLED: 'false' }); + + res.json({ + ok: true, + pid, + message: req.body?.remove_downloads === true + ? 'Lokales FLUX wird vollständig einschließlich Downloads deinstalliert.' + : 'Lokales FLUX wird deinstalliert. Installationsarchive bleiben für eine schnelle Neuinstallation erhalten.', + }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokales FLUX konnte nicht deinstalliert werden.' }); + } +}); + +app.post('/api/local-image/regenerate-token', requireRole('admin'), (req, res) => { + try { + const token = generateLocalImageToken(); + updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token }); + res.json({ ok: true, token }); + } catch (error) { + res.status(500).json({ error: error.message || 'Lokaler Token konnte nicht erneuert werden.' }); + } +}); + + + +app.get('/api/media-library', async (req, res) => { + try { + const allItems = listMediaLibraryItems({ + source: req.query?.source || 'all', + search: req.query?.q || '', + favoritesOnly: String(req.query?.favorites || '0') === '1', + sort: req.query?.sort || 'newest', + }); + const pageSize = Math.max(12, Math.min(96, Number(req.query?.page_size) || 24)); + const total = allItems.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min(Math.max(1, Number(req.query?.page) || 1), totalPages); + const uploadRoot = resolve(UPLOADS_DIR); + const pageItems = allItems.slice((page - 1) * pageSize, page * pageSize); + const items = await Promise.all(pageItems.map(async item => { + const absolute = resolve(uploadRoot, item.image_path); + let fileSize = null; + let width = Number(item.width) || null; + let height = Number(item.height) || null; + let format = item.format || extname(item.image_path).replace('.', '').toLowerCase() || null; + if (absolute !== uploadRoot && absolute.startsWith(uploadRoot + sep) && existsSync(absolute)) { + try { fileSize = statSync(absolute).size; } catch {} + if (!width || !height) { + try { + const metadata = await sharp(absolute, { failOn: 'none' }).metadata(); + width = metadata.width || width; + height = metadata.height || height; + format = metadata.format || format; + } catch {} + } + } + return { ...item, public_url: `/uploads/${item.image_path}`, file_size: fileSize, width, height, format }; + })); + const summaryItems = listMediaLibraryItems({ + source: 'all', + search: req.query?.q || '', + favoritesOnly: String(req.query?.favorites || '0') === '1', + sort: req.query?.sort || 'newest', + }); + const sourceCounts = summaryItems.reduce((counts, item) => { + for (const itemSource of item.sources || []) counts[itemSource] = (counts[itemSource] || 0) + 1; + return counts; + }, {}); + res.json({ items, pagination: { page, page_size: pageSize, total, total_pages: totalPages }, summary: { total: summaryItems.length, sources: sourceCounts } }); + } catch (error) { res.status(500).json({ error: error.message || 'Medienbibliothek konnte nicht geladen werden.' }); } +}); + +app.patch('/api/media-library/favorite', (req, res) => { + const result = setMediaLibraryFavorite(req.body?.image_path, req.body?.favorite !== false); + if (!result.ok) return res.status(400).json({ error: 'Nur FLUX-Bilder können als Favorit markiert werden.' }); + res.json(result); +}); + +app.delete('/api/media-library/items', (req, res) => { + const requested = [...new Set((Array.isArray(req.body?.image_paths) ? req.body.image_paths : []).map(value => String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()).filter(Boolean))].slice(0, 500); + const referenced = getListingReferencedMediaPaths(); + const protectedPaths = requested.filter(imagePath => referenced.has(imagePath)); + const deletable = requested.filter(imagePath => !referenced.has(imagePath)); + const uploadRoot = resolve(UPLOADS_DIR); + const deletedFiles = []; + for (const imagePath of deletable) { + const absolute = resolve(uploadRoot, imagePath); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep)) continue; + try { if (existsSync(absolute) && statSync(absolute).isFile()) { unlinkSync(absolute); deletedFiles.push(imagePath); } } catch {} + } + removeMediaLibraryRecords(deletable); + res.json({ ok: true, deleted: deletable.length, deleted_files: deletedFiles.length, protected: protectedPaths }); +}); + +app.get('/api/image-batch/profiles', (_req, res) => { + res.json({ items: listImageBatchProfiles() }); +}); + +app.post('/api/image-batch/profiles', (req, res) => { + try { res.status(201).json(createImageBatchProfile(req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht erstellt werden.' }); } +}); + +app.patch('/api/image-batch/profiles/:id', (req, res) => { + try { res.json(updateImageBatchProfile(req.params.id, req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gespeichert werden.' }); } +}); + +app.delete('/api/image-batch/profiles/:id', (req, res) => { + try { res.json(deleteImageBatchProfile(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/image-batch/jobs', (req, res) => { + try { res.status(202).json(createImageBatchJob(req.body || {})); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht erstellt werden.' }); } +}); + +app.get('/api/image-batch/jobs', (req, res) => { + try { + res.json(paginateImageBatchJobs({ page: req.query?.page || 1, pageSize: req.query?.page_size || 10, status: req.query?.status || '' })); + } catch (error) { res.status(500).json({ error: error.message || 'Bildaufträge konnten nicht geladen werden.' }); } +}); + +app.get('/api/image-batch/jobs/:id', (req, res) => { + const job = getImageBatchJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/image-batch/jobs/:id/pause', (req, res) => { + try { res.json(pauseImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht pausiert werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/resume', (req, res) => { + try { res.json(resumeImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht fortgesetzt werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/cancel', (req, res) => { + try { res.json(cancelImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht abgebrochen werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/retry', (req, res) => { + try { res.json(retryImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht wiederholt werden.' }); } +}); + +app.post('/api/image-batch/jobs/:id/items/:itemId/retry', (req, res) => { + try { res.json(retryImageBatchItem(req.params.id, req.params.itemId)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildposition konnte nicht wiederholt werden.' }); } +}); + +app.delete('/api/image-batch/jobs/:id', (req, res) => { + try { res.json(deleteImageBatchJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht gelöscht werden.' }); } +}); + +app.get('/api/image-batch/jobs/:id/archive', (req, res) => { + try { + const job = getImageBatchJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); + const outputs = getImageBatchOutputPaths(req.params.id); + if (!outputs.length) return res.status(404).json({ error: 'Dieser Auftrag enthält noch keine fertigen Bilder.' }); + const uploadRoot = resolve(UPLOADS_DIR); + const files = []; + for (const [index, relative] of outputs.entries()) { + const absolute = resolve(uploadRoot, relative); + if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) continue; + files.push({ name: `${String(index + 1).padStart(2, '0')}-${basename(relative)}`, data: readFileSync(absolute) }); + } + if (!files.length) return res.status(404).json({ error: 'Die fertigen Bilddateien wurden nicht gefunden.' }); + const archive = createZip(files); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-bildproduktion-${String(job.id).slice(0, 8)}.zip`); + res.setHeader('Content-Length', archive.length); + res.send(archive); + } catch (error) { res.status(500).json({ error: error.message || 'ZIP-Export konnte nicht erstellt werden.' }); } +}); + +app.post('/api/flux-prompt/jobs', (req, res) => { + try { + const settings = getSettings(); + const job = enqueueFluxPromptJob({ + prompt: req.body?.prompt, + width: req.body?.width, + height: req.body?.height, + seed: req.body?.seed, + steps: req.body?.steps, + style: req.body?.style, + profile: req.body?.profile || settings.flux_default_profile || 'balanced', + promptMode: req.body?.prompt_mode || 'precise', + assistant: req.body?.assistant !== false && String(settings.flux_prompt_assistant || '1') !== '0', + variants: req.body?.variants || settings.flux_default_variants || 1, + seedLock: req.body?.seed_lock === true || String(req.body?.seed_lock || settings.flux_seed_lock || '0') === '1', + }); + res.status(202).json(job); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht erstellt werden.' }); } +}); + +app.get('/api/flux-prompt/jobs', (req, res) => { + const pageData = paginateFluxPromptJobs({ + page: req.query?.page || 1, + pageSize: req.query?.page_size || req.query?.limit || 10, + status: req.query?.status || '', + archived: String(req.query?.archived || '0') === '1', + }); + res.json({ ...pageData, summary: getFluxPromptJobSummary() }); +}); + +app.post('/api/flux-prompt/jobs/archive', (req, res) => { + res.json(archiveFluxPromptJobs(req.body?.ids || [])); +}); + +app.post('/api/flux-prompt/jobs/restore', (req, res) => { + res.json(restoreFluxPromptJobs(req.body?.ids || [])); +}); + +app.delete('/api/flux-prompt/jobs/archive', (req, res) => { + res.json(deleteArchivedFluxPromptJobs(req.body?.ids || [], { all: String(req.query?.all || '0') === '1' })); +}); + +app.get('/api/flux-prompt/jobs/:id', (req, res) => { + const job = getFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/flux-prompt/jobs/:id/cancel', async (req, res) => { + const job = await cancelFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.json(job); +}); + +app.post('/api/flux-prompt/jobs/:id/retry', (req, res) => { + try { + const job = retryFluxPromptJob(req.params.id); + if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); + res.status(202).json(job); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht wiederholt werden.' }); } +}); + +app.delete('/api/flux-prompt/jobs/queue', (_req, res) => { + res.json(clearFluxPromptQueue()); +}); + +app.get('/api/flux-prompt/runtime', async (_req, res) => { + try { + const settings = getSettings(); + const runtime = await getLocalFluxRuntimeInfo(settings, { historyLimit: 20 }); + res.json({ ...runtime, vendoo_jobs: getFluxPromptJobSummary() }); + } catch (error) { + res.status(503).json({ error: error.message || 'ComfyUI-Systemdaten konnten nicht geladen werden.', vendoo_jobs: getFluxPromptJobSummary() }); + } +}); + +app.get('/api/flux-studio/images', (req, res) => { + const favoritesOnly = String(req.query?.favorites || '0') === '1'; + if (req.query?.page !== undefined || req.query?.page_size !== undefined) { + const result = paginateFluxImages({ page: req.query.page, pageSize: req.query.page_size, favoritesOnly }); + return res.json({ + items: result.items.map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })), + pagination: result.pagination, + }); + } + const items = getFluxImages(req.query?.limit || 40, { favoritesOnly }).map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })); + res.json(items); +}); + +app.post('/api/flux-prompt/improve', async (req, res) => { + try { + const prompt = String(req.body?.prompt || '').trim().slice(0, 4000); + if (prompt.length < 4) return res.status(400).json({ error: 'Bitte zuerst einen aussagekräftigen Prompt eingeben.' }); + const settings = getSettings(); + const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); + const model = String(req.body?.model || '').trim() || undefined; + const goal = ['precise','product','creative','cinematic'].includes(req.body?.goal) ? req.body.goal : 'product'; + const preserveSubject = req.body?.preserve_subject !== false; + const avoidText = req.body?.avoid_text !== false; + const goalLabels = { precise: 'präzise und technisch eindeutig', product: 'hochwertige Produkt- und Marketplace-Fotografie', creative: 'kreativ, aufmerksamkeitsstark und visuell eigenständig', cinematic: 'cinematisch, atmosphärisch und fotografisch hochwertig' }; + const systemPrompt = `Du bist Vendoo Prompt Director für FLUX-Bildgenerierung. Optimiere einen vorhandenen Bildprompt, ohne die eigentliche Produktidentität oder Nutzerabsicht zu verfälschen. Ziel: ${goalLabels[goal]}. ${preserveSubject ? 'Motiv, Produktmerkmale, Farben, Anzahl und Perspektiven dürfen nicht frei erfunden oder ausgetauscht werden.' : ''} ${avoidText ? 'Vermeide sichtbare Schrift, Logos, Wasserzeichen und erfundene Marken, sofern der Nutzer sie nicht ausdrücklich verlangt.' : ''} Ergänze nur sinnvolle Angaben zu Motiv, Komposition, Kamera, Licht, Materialwirkung, Hintergrund und Qualitätsmerkmalen. Gib ausschließlich valides JSON aus: {"improved_prompt":"...","notes":"..."}. Der verbesserte Prompt soll direkt in FLUX nutzbar sein und maximal 1800 Zeichen enthalten.`; + const userPrompt = `Originalprompt: +${prompt} + +Gewünschtes Profil: ${goal}. Formuliere den verbesserten Prompt bevorzugt in klarem, natürlichem Englisch, behalte aber ausdrücklich verlangte deutsche Texte unverändert bei.`; + const raw = await generateText(provider, systemPrompt, userPrompt, model); + let parsed = null; + try { parsed = JSON.parse(raw); } catch { + const match = raw.match(/\{[\s\S]*\}/); + if (match) try { parsed = JSON.parse(match[0]); } catch {} + } + const improved = String(parsed?.improved_prompt || raw || '').trim().slice(0, 2000); + if (!improved) throw new Error('Die AI hat keinen verbesserten Prompt zurückgegeben.'); + res.json({ improved_prompt: improved, notes: String(parsed?.notes || '').trim().slice(0, 500), provider, model: model || null }); + } catch (error) { + res.status(500).json({ error: error.message || 'Prompt konnte nicht verbessert werden.' }); + } +}); + +app.post('/api/flux-studio/images', async (req, res) => { + try { + const imagePath = String(req.body?.image_path || '').trim(); + if (!/^ai-generated\/[a-z0-9-]+\.(png|jpe?g|webp)$/i.test(imagePath)) { + return res.status(400).json({ error: 'Ungültiger FLUX-Bildpfad.' }); + } + const absolute = resolve(UPLOADS_DIR, imagePath); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (!absolute.startsWith(imageRoot + sep) || !existsSync(absolute)) return res.status(400).json({ error: 'Bilddatei wurde nicht gefunden.' }); + const metadata = await sharp(absolute).metadata(); + const item = createFluxImage({ + prompt: req.body?.prompt || 'Nachbearbeitete FLUX-Ausgabe', + image_path: imagePath, + width: metadata.width || req.body?.width, + height: metadata.height || req.body?.height, + seed: req.body?.seed, + steps: req.body?.steps, + style: req.body?.style, + edited_from: req.body?.edited_from, + final_prompt: req.body?.final_prompt, + profile: req.body?.profile, + duration_ms: req.body?.duration_ms, + favorite: req.body?.favorite, + job_id: req.body?.job_id, + variant_index: req.body?.variant_index, + prompt_mode: req.body?.prompt_mode, + parameters: req.body?.parameters, + }); + res.status(201).json({ ...item, public_url: `/uploads/${item.image_path}` }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gespeichert werden.' }); } +}); + +app.post('/api/flux-studio/images/:id/copy-to-generator', (req, res) => { + try { + const item = getFluxImage(req.params.id); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + const source = resolve(UPLOADS_DIR, item.image_path); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (!source.startsWith(imageRoot + sep) || !existsSync(source)) return res.status(404).json({ error: 'FLUX-Bilddatei wurde nicht gefunden.' }); + const targetDir = resolve(UPLOADS_DIR, 'allgemein'); + mkdirSync(targetDir, { recursive: true }); + const extension = extname(source).toLowerCase() || '.png'; + const filename = `${randomUUID()}${extension}`; + copyFileSync(source, join(targetDir, filename)); + res.status(201).json({ filename: `allgemein/${filename}` }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht in den Generator übernommen werden.' }); } +}); + +app.patch('/api/flux-studio/images/:id/favorite', (req, res) => { + const item = setFluxImageFavorite(req.params.id, req.body?.favorite !== false); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + res.json({ ...item, public_url: `/uploads/${item.image_path}` }); +}); + +app.delete('/api/flux-studio/images/:id', (req, res) => { + try { + const item = deleteFluxImage(req.params.id); + if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); + const absolute = resolve(UPLOADS_DIR, item.image_path); + const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); + if (absolute.startsWith(imageRoot + sep)) { try { unlinkSync(absolute); } catch {} } + res.json({ ok: true, deleted: item.id }); + } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/ai-model-photos/precheck', async (req, res) => { + try { + const settings = getSettings(); + const payload = normalizeListingPayload(req.body || {}); + const result = await precheckAiModelPhotos(payload, settings); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/ai-model-photos/generate', async (req, res) => { + try { + const settings = getSettings(); + if (String(settings.ai_model_photos_enabled || '1') !== '1') { + return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); + } + const payload = normalizeListingPayload(req.body || {}); + const result = await generateAiModelPhotos({ ...payload, settings }); + const generation = createAiModelGeneration({ + listing_id: payload.listing_id || null, + context: payload.listing_id ? 'listing' : 'generator', + mode: result.mode || result.precheck.mode || payload.mode || 'model', + preset: result.precheck.preset, + variants: result.photos.length, + source_photos: Array.isArray(payload.photos) ? payload.photos : [], + generated_photos: result.photos.map(photo => photo.filename), + prompt_summary: result.prompt_summary, + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, + safety_status: 'safe', + status: 'completed', + }); + const photos = result.photos.map(photo => createAiModelPhoto({ + generation_id: generation.id, + listing_id: payload.listing_id || null, + variant_index: photo.variant_index, + mode: photo.mode || result.mode || payload.mode || 'model', + preset: photo.preset, + image_path: photo.filename, + source_photo: photo.source_photo, + prompt: photo.prompt, + provider: photo.provider || result.provider?.provider || null, + provider_model: photo.provider_model || result.provider?.model || null, + moderation_result: JSON.stringify(photo.moderation || {}), + safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', + })); + updateAiModelGeneration(generation.id, { + generated_photos: photos.map(photo => photo.image_path), + provider: result.provider?.provider || null, + provider_model: result.provider?.model || null, + }); + res.json({ ...result, generation_id: generation.id, photos, provider: result.provider || null }); + } catch (err) { + if (err.code === 'AI_MODEL_PRECHECK_FAILED') { + return res.status(400).json({ error: err.message, precheck: err.precheck || null }); + } + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/listings/:id/ai-model-photos', (req, res) => { + try { + res.json(getAiModelPhotosForListing(parseInt(req.params.id))); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- Generate --- +app.post('/api/generate', async (req, res) => { + try { + const { photos, platform: platformId, ai_provider, ai_model, language, variants, template_id, seller_notes } = req.body; + if (!photos?.length) return res.status(400).json({ error: 'Keine Fotos angegeben' }); + + const platform = platforms[platformId]; + if (!platform) return res.status(400).json({ error: `Unbekannte Plattform: ${platformId}` }); + + const settings = getSettings(); + const selectedTemplate = template_id ? getTemplate(parseInt(template_id)) : null; + const notesParts = [ + settings.seller_notes, + selectedTemplate?.seller_notes, + typeof seller_notes === 'string' ? seller_notes.slice(0, 1000) : '', + ].map(value => String(value || '').trim()).filter(Boolean); + const sellerNotes = [...new Set(notesParts)].join('\n\n'); + const templateTags = Array.isArray(selectedTemplate?.default_tags) ? selectedTemplate.default_tags : []; + const lang = language || selectedTemplate?.language || settings.default_language || 'de'; + const numVariants = Math.min(parseInt(variants) || 1, 3); + const mergeTags = (values) => { + const sourceTags = Array.isArray(values) + ? values + : typeof values === 'string' + ? values.split(',') + : []; + return [...new Set([...sourceTags, ...templateTags].map(tag => String(tag).trim()).filter(Boolean))]; + }; + + const systemPrompt = platform.buildPrompt(sellerNotes, { language: lang, variants: numVariants }); + const images = await prepareForAI(photos); + const result = await generate(ai_provider || settings.default_ai, images, systemPrompt, ai_model); + + const meta = { + brand: result.brand || null, + category: result.category || null, + size: result.size || null, + color: result.color || null, + condition: result.condition || null, + suggested_price: result.suggested_price || null, + }; + + const baseData = { + platform: platformId, + ai_provider: ai_provider || settings.default_ai, + photos, seller_notes: sellerNotes, language: lang, + }; + + if (numVariants > 1 && result.variants) { + const variantsOut = result.variants.map(v => { + const item = { ...baseData, title: v.title || '', description: v.description || '', tags: mergeTags(v.tags || v.hashtags || []), ...meta, price: meta.suggested_price }; + item.description_html = wrapInTemplate(item, platformId); + return item; + }); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + return res.json({ variants: variantsOut, meta, fees }); + } + + const listing = { ...baseData, title: result.title || '', description: result.description || '', tags: mergeTags(result.tags || result.hashtags || []), ...meta, price: meta.suggested_price }; + const descHtml = wrapInTemplate(listing, platformId); + const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; + res.json({ listing, meta, fees, description_html: descHtml }); + } catch (err) { + console.error('Generate error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Fee calculator --- +app.get('/api/fees', (req, res) => { + const price = parseFloat(req.query.price) || 0; + const platform = req.query.platform; + if (platform) return res.json(calculateFees(platform, price)); + res.json(getAllFees(price)); +}); + + +// --- Browser extension center --- +const EXTENSION_VERSION = '1.9.0'; +const EXTENSION_TARGETS = { + chrome: { folder: join(__dirname, 'extensions', 'chrome'), package: `vendoo-link-chrome-${EXTENSION_VERSION}.zip` }, + edge: { folder: join(__dirname, 'extensions', 'edge'), package: `vendoo-link-edge-${EXTENSION_VERSION}.zip` }, + firefox: { folder: join(__dirname, 'extensions', 'firefox'), package: `vendoo-link-firefox-${EXTENSION_VERSION}.zip` }, + safari: { folder: join(__dirname, 'extensions', 'safari'), package: `vendoo-link-safari-${EXTENSION_VERSION}.zip` }, +}; + +function openLocalFolder(folderPath) { + try { + const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(command, [folderPath], { detached: true, stdio: 'ignore', windowsHide: true }); + child.unref(); + return true; + } catch { return false; } +} + +app.get('/api/extensions/info', (_req, res) => { + const browsers = {}; + for (const [id, target] of Object.entries(EXTENSION_TARGETS)) { + browsers[id] = { path: target.folder, available: existsSync(target.folder), package: target.package }; + } + res.json({ version: EXTENSION_VERSION, platform: process.platform, browsers }); +}); + +app.post('/api/extensions/prepare', (req, res) => { + const browser = String(req.body?.browser || '').toLowerCase(); + const target = EXTENSION_TARGETS[browser]; + if (!target) return res.status(400).json({ error: 'Unbekannter Browser' }); + if (!existsSync(target.folder)) return res.status(404).json({ error: 'Extension-Ordner fehlt im Paket' }); + res.json({ ok: true, browser, path: target.folder, opened: openLocalFolder(target.folder), version: EXTENSION_VERSION }); +}); + +app.get('/api/extensions/download/:browser', (req, res) => { + const browser = String(req.params.browser || '').toLowerCase(); + const target = EXTENSION_TARGETS[browser]; + if (!target) return res.status(404).json({ error: 'Unbekannter Browser' }); + const packagePath = join(__dirname, 'extensions', 'packages', target.package); + if (!existsSync(packagePath)) return res.status(404).json({ error: 'Extension-Paket fehlt' }); + res.download(packagePath, target.package); +}); + + +// --- Article Quality Center --- +app.get('/api/quality-center', (req, res) => { + try { + res.json(listQualityCenter({ + query: req.query.q || '', + status: req.query.status || 'all', + grade: req.query.grade || 'all', + page: req.query.page || 1, + pageSize: req.query.page_size || 10, + })); + } catch (error) { + res.status(500).json({ error: error.message || 'Quality Center konnte nicht geladen werden.' }); + } +}); + +app.get('/api/quality-center/:id', async (req, res) => { + try { + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: false }); + if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + report.history = getQualityHistory(req.params.id); + report.comparison = compareQualityReports(req.params.id); + res.json(report); + } catch (error) { + res.status(500).json({ error: error.message || 'Artikelqualität konnte nicht analysiert werden.' }); + } +}); + +app.post('/api/quality-center/:id/analyze', async (req, res) => { + try { + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); + if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + report.history = getQualityHistory(req.params.id); + report.comparison = compareQualityReports(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'quality.analyzed', 'listing', String(req.params.id), `Score ${report.score}`, getClientIp(req)); + res.json(report); + } catch (error) { + res.status(500).json({ error: error.message || 'Qualitätsanalyse fehlgeschlagen.' }); + } +}); + +app.post('/api/quality-center/:id/ai-suggestions', async (req, res) => { + try { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + const fields = Array.isArray(req.body?.fields) ? req.body.fields.filter(field => ['title','description','tags'].includes(field)) : ['title','description','tags']; + if (!fields.length) return res.status(400).json({ error: 'Keine verbesserbaren Felder gewählt.' }); + const settings = getSettings(); + const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); + const model = String(req.body?.model || '').trim() || undefined; + const mode = ['balanced','concise','sales','seo'].includes(req.body?.mode) ? req.body.mode : 'balanced'; + const fieldContract = { + title: fields.includes('title'), description: fields.includes('description'), tags: fields.includes('tags'), + }; + const systemPrompt = `Du bist das Vendoo Artikel Quality Center. Verbessere ausschließlich die ausdrücklich gewählten Felder eines bestehenden Gebrauchtartikel-Angebots. Erfinde keine Marke, Größe, Farbe, technischen Daten, Schäden, Echtheit, Lieferumfang oder Produkteigenschaften. Bestehende Tatsachen müssen erhalten bleiben. Profil: ${mode}. Plattform: ${listing.platform || 'allgemein'}. Gewählte Felder: ${fields.join(', ')}. Gib ausschließlich valides JSON aus: {"title":null oder "...","description":null oder "...","tags":null oder ["..."],"reasons":["..."]}. Nicht gewählte Felder müssen null sein.`; + const userPrompt = JSON.stringify({ + title: listing.title || '', description: listing.description || '', tags: listing.tags || [], brand: listing.brand || '', + category: listing.category || '', size: listing.size || '', color: listing.color || '', condition: listing.condition || '', + seller_notes: listing.seller_notes || '', constraints: fieldContract, + }, null, 2); + const raw = await generateText(provider, systemPrompt, userPrompt, model); + let parsed = null; + try { parsed = JSON.parse(raw); } catch { + const match = String(raw || '').match(/\{[\s\S]*\}/); + if (match) try { parsed = JSON.parse(match[0]); } catch {} + } + if (!parsed || typeof parsed !== 'object') throw new Error('Die AI hat keinen gültigen Verbesserungsvorschlag geliefert.'); + const suggestions = { + title: fields.includes('title') ? String(parsed.title || '').trim().slice(0, 180) : null, + description: fields.includes('description') ? String(parsed.description || '').trim().slice(0, 6000) : null, + tags: fields.includes('tags') ? [...new Set((Array.isArray(parsed.tags) ? parsed.tags : []).map(value => String(value).trim()).filter(Boolean))].slice(0, 20) : null, + reasons: (Array.isArray(parsed.reasons) ? parsed.reasons : []).map(value => String(value).trim()).filter(Boolean).slice(0, 8), + }; + res.json({ listing_id: listing.id, provider, model: model || null, mode, original: { title: listing.title, description: listing.description, tags: listing.tags }, suggestions }); + } catch (error) { + res.status(500).json({ error: error.message || 'AI-Verbesserung konnte nicht erstellt werden.' }); + } +}); + +app.post('/api/quality-center/:id/apply-suggestions', async (req, res) => { + try { + const current = getListing(req.params.id); + if (!current) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); + const patch = {}; + if (req.body?.title !== undefined) patch.title = String(req.body.title || '').trim().slice(0, 180); + if (req.body?.description !== undefined) { + patch.description = String(req.body.description || '').trim().slice(0, 6000); + patch.description_html = sanitizeRichHtml(plainTextToSafeHtml(patch.description)); + } + if (req.body?.tags !== undefined) patch.tags = Array.isArray(req.body.tags) ? req.body.tags.map(value => String(value).trim()).filter(Boolean).slice(0, 20) : current.tags; + if (!Object.keys(patch).length) return res.status(400).json({ error: 'Keine bestätigten Änderungen vorhanden.' }); + const updated = updateListing(req.params.id, normalizeListingPayload(patch)); + const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); + if (req.user) auditLog(req.user.id, req.user.email, 'quality.ai_applied', 'listing', String(req.params.id), Object.keys(patch).join(', '), getClientIp(req)); + res.json({ listing: updated, report }); + } catch (error) { + res.status(500).json({ error: error.message || 'Verbesserungen konnten nicht übernommen werden.' }); + } +}); + +// --- Listings CRUD --- +app.post('/api/listings', (req, res) => { + try { + const listing = createListing(normalizeListingPayload({ ...req.body, created_by: req.user?.id || null, updated_by: req.user?.id || null })); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(listing.id), listing.title, getClientIp(req)); + res.json(listing); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.get('/api/listings', (req, res) => { + res.json(getListings(req.query.q, req.query.platform, req.query.status)); +}); + +app.get('/api/listings/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(listing); +}); + +app.put('/api/listings/:id', (req, res) => { + const lockValidation = validateResourceLock({ resourceType: 'listing', resourceId: req.params.id, userId: req.user.id, token: req.get('x-edit-lock') || req.body?._edit_lock }); + if (!lockValidation.valid) return res.status(409).json({ error: `${lockValidation.lock?.user_name || lockValidation.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code: 'EDIT_LOCKED', lock: lockValidation.lock }); + const payload = { ...req.body }; + delete payload._edit_lock; + const listing = updateListing(req.params.id, normalizeListingPayload({ ...payload, updated_by: req.user?.id || null })); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', req.params.id, listing.title, getClientIp(req)); + res.json(listing); +}); + +app.delete('/api/listings/:id', (req, res) => { + if (req.user) auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, getClientIp(req)); + softDeleteListing(req.params.id); + res.json({ ok: true }); +}); + +// --- Duplicate --- +app.post('/api/listings/:id/duplicate', (req, res) => { + const copy = duplicateListing(req.params.id); + if (!copy) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(copy.id), copy.title, getClientIp(req)); + res.json(copy); +}); + +// --- Trash --- +app.get('/api/trash', (_req, res) => { + res.json(getDeletedListings()); +}); + +app.get('/api/trash/count', (_req, res) => { + res.json({ count: getTrashCount() }); +}); + +app.post('/api/trash/:id/restore', (req, res) => { + restoreListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash/:id', (req, res) => { + permanentlyDeleteListing(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, getClientIp(req)); + res.json({ ok: true }); +}); + +app.delete('/api/trash', (req, res) => { + const deleted = getDeletedListings(); + for (const l of deleted) permanentlyDeleteListing(l.id); + if (req.user) auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${deleted.length} Einträge`, getClientIp(req)); + res.json({ ok: true, deleted: deleted.length }); +}); + +// --- Templates CRUD --- +app.get('/api/templates', (_req, res) => res.json(getTemplates())); +app.post('/api/templates', (req, res) => res.json(createTemplate(req.body))); +app.put('/api/templates/:id', (req, res) => { + const t = updateTemplate(req.params.id, req.body); + if (!t) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(t); +}); +app.delete('/api/templates/:id', (req, res) => { + deleteTemplate(req.params.id); + res.json({ ok: true }); +}); + +// --- Inventory / Storage --- +app.get('/api/inventory/locations', (_req, res) => res.json(getStorageLocations())); +app.get('/api/inventory/unassigned', (_req, res) => res.json(getUnassignedListings())); +app.get('/api/inventory/location/:loc', (req, res) => res.json(getListingsByLocation(req.params.loc))); +app.put('/api/inventory/bulk-location', (req, res) => { + const { ids, location } = req.body; + if (!ids?.length) return res.status(400).json({ error: 'ids required' }); + const count = bulkUpdateLocation(ids, location || ''); + res.json({ ok: true, updated: count }); +}); + +// --- CSV Export --- +app.get('/api/export/csv', (req, res) => { + const listings = getListingsForExport(req.query.platform, req.query.status); + const headers = ['ID', 'Plattform', 'Status', 'Titel', 'Beschreibung', 'Tags', 'Marke', 'Kategorie', 'Groesse', 'Farbe', 'Zustand', 'Preis', 'Erstellt']; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + const rows = listings.map(l => [ + l.id, l.platform, l.status || 'active', l.title, l.description, + (l.tags || []).join('; '), l.brand, l.category, l.size, l.color, + l.condition, l.price, l.created_at, + ].map(escape).join(',')); + const csv = [headers.join(','), ...rows].join('\n'); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename=vendoo-export.csv'); + res.send('' + csv); +}); + +// --- Settings --- +app.get('/api/settings', (_req, res) => { + const settings = getSettings(); + settings.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + settings.has_openai_key = !!process.env.OPENAI_API_KEY; + settings.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + settings.has_etsy_key = !!process.env.ETSY_API_KEY; + settings.etsy_connected = etsyApi.isConnected(); + settings.has_ebay_credentials = ebayApi.hasCredentials(); + settings.ebay_connected = ebayApi.isConnected(); + settings.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + settings.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + settings.public_base_url = process.env.PUBLIC_BASE_URL || ''; + settings.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + settings.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + settings.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui'; + settings.ai_model_photo_local_enabled = settings.ai_model_photo_local_enabled || '1'; + settings.ai_model_photo_local_require_token = settings.ai_model_photo_local_require_token || '1'; + settings.ai_model_photo_use_source_reference = settings.ai_model_photo_use_source_reference || '1'; + settings.ai_model_photo_reference_strength = settings.ai_model_photo_reference_strength || '70'; + settings.ai_model_photo_local_resolution = settings.ai_model_photo_local_resolution || '768'; + settings.ai_model_photo_preserve_logos = settings.ai_model_photo_preserve_logos || '1'; + settings.flux_runtime_profile = settings.flux_runtime_profile || 'auto'; + settings.flux_default_profile = settings.flux_default_profile || 'balanced'; + settings.flux_prompt_assistant = settings.flux_prompt_assistant || '1'; + settings.flux_default_style = settings.flux_default_style || 'photorealistic'; + settings.flux_default_format = settings.flux_default_format || '768x768'; + settings.flux_default_variants = settings.flux_default_variants || '1'; + settings.flux_seed_lock = settings.flux_seed_lock || '0'; + settings.flux_max_parallel_jobs = settings.flux_max_parallel_jobs || '1'; + settings.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; + res.json(settings); +}); + +app.put('/api/settings', requireRole('admin'), (req, res) => { + const { anthropic_key, openai_key, openrouter_key, etsy_key, ebay_client_id, ebay_client_secret, ebay_ru_name, ebay_sandbox, ollama_url, public_base_url, ai_model_photo_local_token, ai_model_photo_local_url, ai_model_photo_local_workflow_path, ai_model_photo_local_install_path, ...dbSettings } = req.body; + let envContent = readRuntimeConfig(); + + if (anthropic_key !== undefined) { envContent = setConfigValue(envContent, 'ANTHROPIC_API_KEY', anthropic_key); process.env.ANTHROPIC_API_KEY = anthropic_key; } + if (openai_key !== undefined) { envContent = setConfigValue(envContent, 'OPENAI_API_KEY', openai_key); process.env.OPENAI_API_KEY = openai_key; } + if (openrouter_key !== undefined) { envContent = setConfigValue(envContent, 'OPENROUTER_API_KEY', openrouter_key); process.env.OPENROUTER_API_KEY = openrouter_key; } + if (etsy_key !== undefined) { envContent = setConfigValue(envContent, 'ETSY_API_KEY', etsy_key); process.env.ETSY_API_KEY = etsy_key; } + if (ebay_client_id !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_ID', ebay_client_id); process.env.EBAY_CLIENT_ID = ebay_client_id; } + if (ebay_client_secret !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_SECRET', ebay_client_secret); process.env.EBAY_CLIENT_SECRET = ebay_client_secret; } + if (ebay_ru_name !== undefined) { envContent = setConfigValue(envContent, 'EBAY_RU_NAME', ebay_ru_name); process.env.EBAY_RU_NAME = ebay_ru_name; } + if (ebay_sandbox !== undefined) { envContent = setConfigValue(envContent, 'EBAY_SANDBOX', ebay_sandbox); process.env.EBAY_SANDBOX = ebay_sandbox; } + if (ollama_url !== undefined) { envContent = setConfigValue(envContent, 'OLLAMA_URL', ollama_url); process.env.OLLAMA_URL = ollama_url; } + if (ai_model_photo_local_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_TOKEN', ai_model_photo_local_token); process.env.LOCAL_IMAGE_TOKEN = ai_model_photo_local_token; } + if (ai_model_photo_local_url !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_URL', String(ai_model_photo_local_url || '').trim().replace(/\/$/, '')); process.env.LOCAL_IMAGE_URL = String(ai_model_photo_local_url || '').trim().replace(/\/$/, ''); } + if (ai_model_photo_local_workflow_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_WORKFLOW_PATH', ai_model_photo_local_workflow_path); process.env.LOCAL_IMAGE_WORKFLOW_PATH = ai_model_photo_local_workflow_path; } + if (ai_model_photo_local_install_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_INSTALL_PATH', ai_model_photo_local_install_path); process.env.LOCAL_IMAGE_INSTALL_PATH = ai_model_photo_local_install_path; } + if (dbSettings.ai_model_photo_local_enabled !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_ENABLED', String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_ENABLED = String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'; } + if (dbSettings.ai_model_photo_local_require_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_REQUIRE_TOKEN', String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_REQUIRE_TOKEN = String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'; } + if (public_base_url !== undefined) { + const cleanPublicBaseUrl = String(public_base_url || '').trim().replace(/\/$/, ''); + envContent = setConfigValue(envContent, 'PUBLIC_BASE_URL', cleanPublicBaseUrl); + process.env.PUBLIC_BASE_URL = cleanPublicBaseUrl; + } + + writeRuntimeConfig(envContent); + if (req.user) auditLog(req.user.id, req.user.email, 'settings.updated', null, null, null, getClientIp(req)); + const result = updateSettings(dbSettings); + result.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; + result.has_openai_key = !!process.env.OPENAI_API_KEY; + result.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; + result.has_etsy_key = !!process.env.ETSY_API_KEY; + result.etsy_connected = etsyApi.isConnected(); + result.has_ebay_credentials = ebayApi.hasCredentials(); + result.ebay_connected = ebayApi.isConnected(); + result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; + result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; + result.public_base_url = process.env.PUBLIC_BASE_URL || ''; + result.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || result.ai_model_photo_local_url || 'http://127.0.0.1:8188'; + result.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || result.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; + result.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || result.ai_model_photo_local_install_path || 'local-ai/comfyui'; + result.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; + res.json(result); +}); + +// --- HTML Templates --- +app.get('/api/html-templates', (_req, res) => res.json(getHtmlTemplates())); + +app.post('/api/html-wrap', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const html = wrapInTemplate(listing, platform || listing.platform); + res.json({ html }); +}); + +// Live rendering for the generator and listing editor. No data is persisted here. +app.post('/api/html-render', (req, res) => { + try { + const listing = normalizeListingPayload(req.body?.listing || req.body || {}); + const platform = req.body?.platform || listing.platform || 'vinted'; + res.json({ html: wrapInTemplate(listing, platform) }); + } catch (err) { + res.status(400).json({ error: err.message || 'HTML konnte nicht gerendert werden' }); + } +}); + +// --- Publishing Hardening 1.29.0 --- +app.get('/api/publishing/capabilities', (_req, res) => { + res.json({ + ...getPublishCapabilities(), + connections: { + ebay: { connected: ebayApi.isConnected(), configured: ebayApi.hasCredentials(), sandbox: process.env.EBAY_SANDBOX === 'true' }, + etsy: { connected: etsyApi.isConnected(), configured: Boolean(process.env.ETSY_API_KEY) }, + extension: { version: '1.9.0', platforms: ['vinted', 'ebay-ka'] }, + }, + }); +}); + +app.get('/api/publishing/jobs', (req, res) => { + res.json(paginatePublishingJobs({ + page: req.query.page, + pageSize: req.query.page_size, + status: req.query.status || '', + platform: req.query.platform || '', + q: req.query.q || '', + })); +}); + +app.get('/api/publishing/jobs/:id', (req, res) => { + const job = getPublishingJob(req.params.id); + if (!job) return res.status(404).json({ error: 'Publishing-Auftrag nicht gefunden' }); + res.json(job); +}); + +app.post('/api/publishing/jobs', (req, res) => { + try { + const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : [req.body?.listing_id]; + const platform = req.body?.platform || null; + const action = req.body?.action || 'publish'; + const results = []; + for (const listingId of listingIds.filter(Boolean)) { + results.push(enqueuePublishingJob({ listingId, platform, action, force: Boolean(req.body?.force), payload: req.body?.payload || {} })); + } + res.status(results.some(item => !item.duplicate) ? 201 : 200).json({ jobs: results.map(item => item.job), duplicates: results.filter(item => item.duplicate).length }); + } catch (error) { + const status = error.code === 'DUPLICATE_PUBLISH' ? 409 : 400; + res.status(status).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +app.post('/api/publishing/jobs/:id/retry', (req, res) => { + try { res.json(retryPublishingJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.post('/api/publishing/jobs/:id/cancel', (req, res) => { + try { res.json(cancelPublishingJob(req.params.id)); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.post('/api/publishing/jobs/:id/action', (req, res) => { + try { + const source = getPublishingJob(req.params.id); + if (!source) return res.status(404).json({ error: 'Auftrag nicht gefunden' }); + const action = req.body?.action; + if (!['sync', 'update', 'end'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion' }); + const result = enqueuePublishingJob({ + listingId: source.listing_id, + platform: source.platform, + action, + force: true, + payload: { offerId: source.offer_id, externalId: source.external_id }, + }); + res.status(201).json(result.job); + } catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.delete('/api/publishing/jobs', (req, res) => { + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids : String(req.query.ids || '').split(',').filter(Boolean); + res.json({ deleted: deletePublishingJobs(ids) }); + } catch (error) { res.status(400).json({ error: error.message }); } +}); + +app.get('/api/publishing/events', (req, res) => { + res.json(listPublishingEvents({ limit: req.query.limit, level: req.query.level || '', platform: req.query.platform || '' })); +}); + +app.post('/api/extension/publishing/claim', (req, res) => { + const job = claimExtensionJob({ platform: req.body?.platform || 'vinted', clientId: req.body?.client_id || 'vendoo-link' }); + if (!job) return res.status(204).end(); + res.json(job); +}); + +app.post('/api/extension/publishing/:id/complete', (req, res) => { + try { res.json(completeExtensionJob(req.params.id, req.body || {})); } + catch (error) { res.status(400).json({ error: error.message }); } +}); + +// --- Publishing --- +const PUBLISH_URLS = { + vinted: 'https://www.vinted.de/items/new', + 'ebay-ka': 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', + 'ebay-de': 'https://www.ebay.de/sell/create', +}; + +app.get('/api/publish/info/:id', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const logs = getPublishLogs(listing.id); + const platform = listing.platform; + const publishUrl = PUBLISH_URLS[platform] || null; + const canApiPublish = (platform === 'etsy' && etsyApi.isConnected()) || (platform === 'ebay-de' && ebayApi.isConnected()); + res.json({ listing, logs, publishUrl, canApiPublish }); +}); + +app.post('/api/publish/smart-copy', (req, res) => { + const { listing_id, platform } = req.body; + const listing = getListing(listing_id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const targetPlatform = platform || listing.platform; + const publishUrl = PUBLISH_URLS[targetPlatform] || null; + createPublishLog(listing_id, targetPlatform, 'copied'); + const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : listing.tags; + res.json({ + publishUrl, + fields: { + title: listing.title, + description: listing.description, + tags, + price: listing.price || '', + brand: listing.brand || '', + category: listing.category || '', + size: listing.size || '', + color: listing.color || '', + condition: listing.condition || '', + }, + photos: listing.photos, + }); +}); + +app.post('/api/publish/mark', (req, res) => { + const { listing_id, platform, status, external_id, external_url } = req.body; + const target = platform || 'unknown'; + const activeJob = db.prepare(`SELECT id FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND mode = 'extension' + AND status IN ('awaiting_user','running') ORDER BY created_at LIMIT 1`).get(listing_id, target); + if (activeJob) { + const job = completeExtensionJob(activeJob.id, { status: status || 'published', externalId: external_id || null, externalUrl: external_url || null }); + return res.json({ ok: true, job }); + } + createPublishLog(listing_id, target, status || 'published', external_id || null, external_url || null); + res.json({ ok: true, job: null }); +}); + +app.get('/api/publish/status', (req, res) => { + const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); + res.json(getPublishStatus(ids)); +}); + +app.post('/api/publish/batch-photos', async (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); + const allPhotos = []; + for (const id of listing_ids) { + const listing = getListing(id); + if (listing?.photos) { + for (const p of listing.photos) allPhotos.push(p); + } + } + res.json({ photos: allPhotos }); +}); + +app.post('/api/photos/archive', (req, res) => { + const requested = Array.isArray(req.body?.photos) ? req.body.photos : []; + const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : []; + const photos = [...requested]; + for (const id of listingIds) { + const listing = getListing(Number(id)); + if (listing?.photos?.length) photos.push(...listing.photos); + } + + const unique = [...new Set(photos.map(photo => String(photo || '').replace(/\\/g, '/')).filter(Boolean))]; + if (unique.length < 2) return res.status(400).json({ error: 'Für ein ZIP werden mindestens zwei Fotos benötigt' }); + + const uploadRoot = resolve(UPLOADS_DIR); + const files = []; + for (const [index, relative] of unique.entries()) { + const absolute = resolve(uploadRoot, relative); + if (absolute !== uploadRoot && !absolute.startsWith(uploadRoot + sep)) continue; + if (!existsSync(absolute) || !statSync(absolute).isFile()) continue; + const originalName = basename(relative) || `foto-${index + 1}.jpg`; + const prefix = String(index + 1).padStart(2, '0'); + files.push({ name: `${prefix}-${originalName}`, data: readFileSync(absolute) }); + } + + if (files.length < 2) return res.status(404).json({ error: 'Nicht genügend Fotodateien gefunden' }); + const archive = createZip(files); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=vendoo-fotos-${date}.zip`); + res.setHeader('Content-Length', archive.length); + res.send(archive); +}); + +// --- Etsy OAuth --- +app.get('/auth/etsy', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + const url = etsyApi.getAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte ETSY_API_KEY in Einstellungen setzen.`); + } +}); + +app.get('/auth/etsy/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; + await etsyApi.handleCallback(code, state, redirectUri); + res.send('

                                                                      Etsy verbunden!

                                                                      Du kannst dieses Fenster schließen.

                                                                      '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/etsy/status', (_req, res) => { + res.json({ connected: etsyApi.isConnected(), hasApiKey: !!process.env.ETSY_API_KEY }); +}); + +app.post('/api/publish/etsy', (req, res) => { + try { + const result = enqueuePublishingJob({ listingId: req.body?.listing_id, platform: 'etsy', action: 'publish', force: Boolean(req.body?.force) }); + res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); + } catch (error) { + res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +// --- Extension Queue --- +app.get('/api/extension/queue', (req, res) => { + const platform = req.query.platform || 'vinted'; + const jobRows = db.prepare(`SELECT j.id AS publishing_job_id, j.status AS publishing_job_status, l.* + FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id + WHERE j.platform = ? AND j.mode = 'extension' AND j.status = 'awaiting_user' + ORDER BY j.created_at`).all(platform).map(row => { + try { row.photos = JSON.parse(row.photos || '[]'); } catch { row.photos = []; } + try { row.tags = JSON.parse(row.tags || '[]'); } catch { row.tags = []; } + return row; + }); + const all = getListings(null, platform, 'active'); + const queuedIds = new Set(jobRows.map(row => row.id)); + const ids = all.map(l => l.id); + const pubStatus = getPublishStatus(ids); + const fallback = all.filter(l => { + if (queuedIds.has(l.id)) return false; + const ps = pubStatus[l.id]; + return !ps || !ps[platform] || ps[platform] === 'pending' || ps[platform] === 'copied'; + }); + const published = all.filter(l => pubStatus[l.id]?.[platform] === 'published'); + res.json({ queue: [...jobRows, ...fallback], published, total: all.length, hardened_jobs: jobRows.length }); +}); + +app.get('/api/extension/listing/:id/photos', (req, res) => { + const listing = getListing(req.params.id); + if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); + const baseUrl = resolvePublicBaseUrl(req); + const photoUrls = (listing.photos || []).map(filename => `${baseUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`); + res.json({ photos: photoUrls, listing }); +}); + +// --- eBay OAuth --- +app.get('/auth/ebay', (req, res) => { + try { + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + const url = ebayApi.getOAuthUrl(redirectUri); + res.redirect(url); + } catch (err) { + res.status(400).send(`Fehler: ${err.message}. Bitte eBay Credentials in Einstellungen setzen.`); + } +}); + +app.get('/auth/ebay/callback', async (req, res) => { + try { + const { code, state } = req.query; + const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; + await ebayApi.handleCallback(code, state, redirectUri); + res.send('

                                                                      eBay verbunden!

                                                                      Du kannst dieses Fenster schließen.

                                                                      '); + } catch (err) { + res.status(500).send(`OAuth Fehler: ${err.message}`); + } +}); + +app.get('/api/ebay/status', (_req, res) => { + res.json({ + connected: ebayApi.isConnected(), + hasCredentials: ebayApi.hasCredentials(), + sandbox: process.env.EBAY_SANDBOX === 'true', + }); +}); + +app.get('/api/ebay/policies', async (_req, res) => { + try { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + const policies = await ebayApi.getPolicies(); + res.json(policies); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// --- eBay Queue --- +app.get('/api/ebay/queue', (_req, res) => { + res.json(getEbayQueue()); +}); + +app.post('/api/ebay/queue', (req, res) => { + const { listing_ids } = req.body; + if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); + const added = addToEbayQueue(listing_ids); + res.json({ added: added.length, queue: getEbayQueue() }); +}); + +app.delete('/api/ebay/queue/:id', (req, res) => { + removeFromEbayQueue(req.params.id); + res.json({ ok: true }); +}); + +app.delete('/api/ebay/queue', (req, res) => { + clearEbayQueue(req.query.status || null); + res.json({ ok: true }); +}); + +app.post('/api/ebay/publish/:id', (req, res) => { + try { + const result = enqueuePublishingJob({ listingId: req.params.id, platform: 'ebay-de', action: 'publish', force: Boolean(req.body?.force) }); + res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); + } catch (error) { + res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); + } +}); + +app.post('/api/ebay/queue/process', async (_req, res) => { + if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); + + const pending = getEbayQueue('pending'); + const results = []; + + for (const item of pending) { + updateEbayQueue(item.id, { status: 'uploading' }); + try { + const listing = getListing(item.listing_id); + if (!listing) { updateEbayQueue(item.id, { status: 'failed', error: 'Listing nicht gefunden' }); continue; } + + const sku = listing.sku || `AL-${listing.id}`; + const result = await ebayApi.createAndPublish(listing, sku); + + updateEbayQueue(item.id, { status: 'published', ebay_item_id: result.listingId, ebay_url: result.url }); + createPublishLog(listing.id, 'ebay-de', 'published', result.listingId, result.url); + results.push({ id: item.id, listing_id: item.listing_id, status: 'published', url: result.url }); + } catch (err) { + console.error(`eBay queue item ${item.id} failed:`, err); + updateEbayQueue(item.id, { status: 'failed', error: err.message, retries: (item.retries || 0) + 1 }); + results.push({ id: item.id, listing_id: item.listing_id, status: 'failed', error: err.message }); + } + } + + res.json({ processed: results.length, results, queue: getEbayQueue() }); +}); + +app.post('/api/ebay/queue/retry', (_req, res) => { + const failed = getEbayQueue('failed'); + for (const item of failed) { + updateEbayQueue(item.id, { status: 'pending', error: null }); + } + res.json({ retried: failed.length, queue: getEbayQueue() }); +}); + +// --- Categories --- +app.get('/api/categories/:platform', (req, res) => { + const tree = getCategories(req.params.platform); + const flat = getCategoriesFlat(req.params.platform); + res.json({ tree, flat }); +}); + +app.get('/api/categories/:platform/match', (req, res) => { + const q = req.query.q || ''; + res.json(matchCategories(req.params.platform, q)); +}); + +// --- Studio Flow Dashboard --- +app.get('/api/dashboard/workflow', (req, res) => { + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 12) : 3; + res.json(getDashboardWorkflow(limit)); +}); + +app.get('/api/dashboard/analytics', (req, res) => { + const days = Number.parseInt(req.query.days, 10) || 30; + res.json(getDashboardAnalytics(days)); +}); + +// --- Dashboard Stats --- +app.get('/api/dashboard/stats', (_req, res) => { + const listings = getListings(); + const total = listings.length; + const active = listings.filter(l => l.status === 'active').length; + const sold = listings.filter(l => l.status === 'sold').length; + const reserved = listings.filter(l => l.status === 'reserved').length; + + const totalValue = listings.reduce((s, l) => s + (l.price || 0), 0); + const soldValue = listings.filter(l => l.status === 'sold').reduce((s, l) => s + (l.price || 0), 0); + + const platforms = {}; + for (const l of listings) { + platforms[l.platform] = (platforms[l.platform] || 0) + 1; + } + + const now = new Date(); + const timeline = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().slice(0, 10); + const count = listings.filter(l => l.created_at && l.created_at.startsWith(dateStr)).length; + timeline.push({ date: dateStr, label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }), count }); + } + + const recent = listings.slice(0, 5).map(l => ({ + id: l.id, title: l.title, platform: l.platform, + price: l.price, sku: l.sku, status: l.status, + photo: l.photos?.[0] || null, + created_at: l.created_at, + })); + + res.json({ total, active, sold, reserved, totalValue, soldValue, platforms, timeline, recent }); +}); + +// --- Operations Center 1.30.0: Backup, Restore, Updates & Extension diagnostics --- +app.get('/api/operations/status', requireRole('admin'), (_req, res) => { + try { res.json(getOperationsStatus()); } + catch (error) { res.status(500).json({ error: error.message || 'Operationsstatus konnte nicht geladen werden.' }); } +}); + +app.get('/api/operations/settings', requireRole('admin'), (_req, res) => { + try { res.json(getOperationsStatus().settings); } + catch (error) { res.status(500).json({ error: error.message || 'Betriebseinstellungen konnten nicht geladen werden.' }); } +}); + +app.put('/api/operations/settings', requireRole('admin'), (req, res) => { + try { + const settings = updateOperationSettings(req.body || {}); + if (req.user) auditLog(req.user.id, req.user.email, 'operations.settings.updated', 'system', null, JSON.stringify(settings), getClientIp(req)); + res.json(settings); + } catch (error) { res.status(400).json({ error: error.message || 'Betriebseinstellungen konnten nicht gespeichert werden.' }); } +}); + +app.get('/api/operations/backups', requireRole('admin'), (_req, res) => { + try { res.json({ backups: listBackups() }); } + catch (error) { res.status(500).json({ error: error.message || 'Backups konnten nicht geladen werden.' }); } +}); + +app.post('/api/operations/backups', requireRole('admin'), async (req, res) => { + try { + const result = await createBackup({ + kind: req.body?.kind || 'manual', + includeUploads: req.body?.include_uploads !== false, + includeConfig: req.body?.include_config === true, + label: req.body?.label || '', + }); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.created', 'backup', result.id, JSON.stringify({ filename: result.filename, sha256: result.sha256 }), getClientIp(req)); + res.status(201).json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } +}); + +app.get('/api/operations/backups/:id/download', requireRole('admin'), (req, res) => { + try { + const row = getBackupPath(req.params.id); + res.download(row.file_path, row.file_name); + } catch (error) { res.status(404).json({ error: error.message || 'Backup wurde nicht gefunden.' }); } +}); + +app.post('/api/operations/backups/:id/verify', requireRole('admin'), (req, res) => { + try { + const result = verifyStoredBackup(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.verified', 'backup', req.params.id, JSON.stringify({ ok: result.ok, errors: result.errors }), getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht geprüft werden.' }); } +}); + +app.delete('/api/operations/backups/:id', requireRole('admin'), (req, res) => { + try { + const result = deleteBackup(req.params.id); + if (req.user) auditLog(req.user.id, req.user.email, 'backup.deleted', 'backup', req.params.id, null, getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht gelöscht werden.' }); } +}); + +app.post('/api/operations/restore/stage', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Backup-ZIP ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Für die sichere Wiederherstellung wird ein Vendoo-ZIP-Backup benötigt.'); + const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); + if (req.user) auditLog(req.user.id, req.user.email, 'restore.staged', 'system', result.id, JSON.stringify({ safety_backup_id: result.safety_backup_id }), getClientIp(req)); + res.status(202).json({ ...result, message: 'Wiederherstellung geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.' }); + } catch (error) { res.status(400).json({ error: error.message || 'Wiederherstellung konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +app.post('/api/operations/updates/check', requireRole('admin'), async (_req, res) => { + try { res.json(await checkForUpdates()); } + catch (error) { res.status(400).json({ error: error.message || 'Updateprüfung fehlgeschlagen.' }); } +}); + +app.post('/api/operations/updates/stage', requireRole('admin'), operationsUpload.single('update'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Kein Update-ZIP ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Vendoo-Updates müssen als ZIP hochgeladen werden.'); + const result = await stageUpdate(readFileSync(req.file.path), req.file.originalname); + if (req.user) auditLog(req.user.id, req.user.email, 'update.staged', 'system', result.id, JSON.stringify({ from: result.version_from, to: result.version_to, safety_backup_id: result.safety_backup_id }), getClientIp(req)); + res.status(202).json({ ...result, message: `Update ${result.version_to} geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.` }); + } catch (error) { res.status(400).json({ error: error.message || 'Update konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +app.post('/api/operations/pending/cancel', requireRole('admin'), (req, res) => { + try { + const result = cancelPendingOperation(); + if (req.user) auditLog(req.user.id, req.user.email, 'operation.cancelled', 'system', result.pending?.id || null, JSON.stringify(result.pending || {}), getClientIp(req)); + res.json(result); + } catch (error) { res.status(400).json({ error: error.message || 'Ausstehende Operation konnte nicht abgebrochen werden.' }); } +}); + +app.get('/api/extensions/diagnostics', requireRole('admin'), (_req, res) => { + try { res.json(getExtensionDiagnostics()); } + catch (error) { res.status(500).json({ error: error.message || 'Extension-Diagnose konnte nicht geladen werden.' }); } +}); + +app.post('/api/extensions/heartbeat', (req, res) => { + try { + res.json(recordExtensionHeartbeat({ + clientId: req.body?.client_id, + browser: req.body?.browser, + version: req.body?.version, + platform: req.body?.platform, + capabilities: req.body?.capabilities, + serverUrl: req.body?.server_url, + userAgent: req.get('user-agent') || '', + })); + } catch (error) { res.status(400).json({ error: error.message || 'Extension-Heartbeat fehlgeschlagen.' }); } +}); + +// Compatibility route: creates a verified database-only ZIP instead of copying an open SQLite file. +app.get('/api/backup', requireRole('admin'), async (req, res) => { + try { + const backup = await createBackup({ kind: 'database', includeUploads: false, includeConfig: false, label: 'datenbank' }); + const row = getBackupPath(backup.id); + res.download(row.file_path, row.file_name); + } catch (error) { res.status(500).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } +}); + +app.post('/api/restore', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Backup-Datei ausgewählt.' }); + try { + if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Direktes Einspielen offener DB-Dateien ist gesperrt. Bitte ein verifiziertes Vendoo-ZIP-Backup verwenden.'); + const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); + res.status(202).json({ ok: true, ...result, message: 'Backup geprüft und vorbereitet. Vendoo beenden und Setup-Menüpunkt 11 ausführen.' }); + } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht vorbereitet werden.' }); } + finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } +}); + +// --- Scheduling --- +app.get('/api/schedule', (_req, res) => { + res.json(getScheduledPublishes()); +}); + +app.post('/api/schedule', (req, res) => { + const { listing_id, platform, scheduled_at } = req.body; + if (!listing_id || !scheduled_at) return res.status(400).json({ error: 'listing_id und scheduled_at erforderlich' }); + const id = schedulePublish(listing_id, platform || 'ebay-de', scheduled_at); + res.json({ ok: true, id, scheduled_at }); +}); + +app.delete('/api/schedule/:id', (req, res) => { + removeScheduledPublish(req.params.id); + res.json({ ok: true }); +}); + +// --- Price Suggestion --- +app.get('/api/price-suggest', async (req, res) => { + const { title, category, condition } = req.query; + if (!title) return res.status(400).json({ error: 'Titel erforderlich' }); + + const listings = getListings(); + const words = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); + + const similar = listings + .filter(l => l.price && l.price > 0) + .map(l => { + const lTitle = (l.title || '').toLowerCase(); + const matchCount = words.filter(w => lTitle.includes(w)).length; + const catMatch = category && l.category && l.category.toLowerCase().includes(category.toLowerCase()) ? 2 : 0; + return { ...l, score: matchCount + catMatch }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + if (!similar.length) return res.json({ suggestion: null, similar: [] }); + + const prices = similar.map(s => s.price); + const avg = prices.reduce((a, b) => a + b, 0) / prices.length; + const min = Math.min(...prices); + const max = Math.max(...prices); + + res.json({ + suggestion: Math.round(avg * 2) / 2, + min, max, avg: Math.round(avg * 100) / 100, + count: similar.length, + similar: similar.slice(0, 5).map(s => ({ title: s.title, price: s.price, platform: s.platform })), + }); +}); + +// --- Providers & Platforms --- +app.get('/api/providers', (_req, res) => res.json(getProviders())); +app.get('/api/platforms', (_req, res) => { + res.json(Object.values(platforms).map(p => ({ + id: p.id, name: p.name, fields: p.fields, + maxTitleLength: p.maxTitleLength, maxTags: p.maxTags, + }))); +}); + +// --- Scheduler Timer --- +async function processScheduledPublishes() { + const due = getDueScheduled(); + for (const item of due) { + updateScheduledPublish(item.id, { status: 'publishing', error: null }); + try { + const result = enqueuePublishingJob({ listingId: item.listing_id, platform: item.platform, action: 'publish' }); + updateScheduledPublish(item.id, { status: result.duplicate ? 'queued' : 'queued', error: result.duplicate ? 'Bereits in der Publishing-Queue' : null }); + console.log(`Scheduled publish ${item.id} → Publishing Job ${result.job.id}`); + } catch (error) { + if (error.code === 'DUPLICATE_PUBLISH') { + updateScheduledPublish(item.id, { status: 'published', error: null }); + } else { + updateScheduledPublish(item.id, { status: 'failed', error: error.message }); + } + } + } + if (due.length) console.log(`Scheduler: ${due.length} fällige Veröffentlichungen an die gehärtete Queue übergeben`); +} + +const scheduledPublishTimer = setInterval(processScheduledPublishes, 60_000); +scheduledPublishTimer.unref?.(); + +// --- Trash cleanup --- +function runTrashCleanup() { + try { + const result = cleanupTrash(30); + if (result.changes > 0) console.log(`Trash cleanup: ${result.changes} alte Einträge endgültig gelöscht`); + } catch (err) { console.error('Trash cleanup error:', err.message); } +} +const trashCleanupTimer = setInterval(runTrashCleanup, 60 * 60 * 1000); // every hour +trashCleanupTimer.unref?.(); + +// --- Start --- +if (!/^(0|false|no|off)$/i.test(String(process.env.LOCAL_IMAGE_ENABLED || 'true'))) { + try { + const settings = getSettings(); + const bootstrap = ensureLocalFluxBootstrap({ + token: process.env.LOCAL_IMAGE_TOKEN || '', + installPath: process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui', + }); + console.log(`Lokaler FLUX-Prompt-Workflow geprüft: ${bootstrap.workflow_path}`); + } catch (error) { + console.error('Lokale AI-Workflow-Selbstheilung fehlgeschlagen:', error.message || error); + } +} else { + console.log('FLUX/ComfyUI ist deaktiviert oder wird als externer Dienst betrieben.'); +} +await loadPlatforms(); +const BIND_HOST = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; +const server = app.listen(PORT, BIND_HOST, async () => { + console.log(`Vendoo ${APP_VERSION} läuft auf http://${BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST}:${PORT}`); + console.log(`Datenverzeichnis: ${runtimePathSummary().data_root}`); + if (BIND_HOST === '0.0.0.0' || BIND_HOST === '::') for (const entry of getLanIpv4Addresses()) console.log(`Vendoo im LAN: http://${entry.address}:${PORT} (${entry.name})`); + processScheduledPublishes(); + runTrashCleanup(); +}); + +function gracefulShutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal}: Vendoo wird geordnet beendet ...`); + clearInterval(scheduledPublishTimer); + clearInterval(trashCleanupTimer); + const forceTimer = setTimeout(() => { + console.error('Shutdown-Zeitlimit erreicht. Prozess wird beendet.'); + process.exit(1); + }, 10_000); + server.close(async () => { + try { await platformKernel.stop(); } catch (error) { console.error('Platform-Kernel-Shutdown fehlgeschlagen:', error.message || error); } + try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {} + try { db.close(); } catch {} + clearTimeout(forceTimer); + console.log('Vendoo wurde sauber beendet.'); + process.exit(0); + }); +} +process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.once('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/setup-gui.ps1 b/setup-gui.ps1 new file mode 100644 index 0000000..b145bd9 --- /dev/null +++ b/setup-gui.ps1 @@ -0,0 +1,237 @@ +# Vendoo 1.36.0 – grafischer Zero-Edit-Installationsassistent +$ErrorActionPreference = 'Stop' +$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SetupScript = Join-Path $VendooDir 'setup.ps1' +$EnvFile = Join-Path $VendooDir '.env' +$EnvExample = Join-Path $VendooDir '.env.example' +$ModeFile = Join-Path $VendooDir '.vendoo-install-mode' + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8) +} + +function Ensure-EnvFile { + if (-not (Test-Path -LiteralPath $EnvFile)) { + if (-not (Test-Path -LiteralPath $EnvExample)) { throw '.env.example fehlt.' } + Copy-Item -LiteralPath $EnvExample -Destination $EnvFile + } +} + +function Get-EnvValue([string]$Key) { + if (-not (Test-Path -LiteralPath $EnvFile)) { return '' } + $line = Get-Content -LiteralPath $EnvFile | Where-Object { $_ -match ('^' + [regex]::Escape($Key) + '=') } | Select-Object -Last 1 + if (-not $line) { return '' } + return ($line -replace ('^' + [regex]::Escape($Key) + '='), '').Trim() +} + +function Set-EnvValue([string]$Key, [string]$Value) { + if ($Value -match "[\r\n]") { throw "Ungültiger mehrzeiliger Wert für $Key." } + Ensure-EnvFile + $content = Get-Content -LiteralPath $EnvFile -Raw + $escaped = [regex]::Escape($Key) + if ($content -match "(?m)^$escaped=") { + $content = [regex]::Replace($content, "(?m)^$escaped=.*$", "$Key=$Value") + } else { + $content = $content.TrimEnd() + "`r`n$Key=$Value`r`n" + } + Write-Utf8NoBom -Path $EnvFile -Content $content +} + +function New-SecureToken { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Test-Port([string]$Text) { + $port = 0 + return [int]::TryParse($Text, [ref]$port) -and $port -ge 1024 -and $port -le 65535 +} + +function Set-Configuration([string]$Target, [string]$Access, [int]$Port, [string]$PublicUrl, [bool]$FluxEnabled) { + Ensure-EnvFile + $token = Get-EnvValue 'VENDOO_SETUP_TOKEN' + if ([string]::IsNullOrWhiteSpace($token)) { + $token = New-SecureToken + Set-EnvValue 'VENDOO_SETUP_TOKEN' $token + } + + Set-EnvValue 'PORT' ([string]$Port) + Set-EnvValue 'VENDOO_HOST_PORT' ([string]$Port) + Set-EnvValue 'VENDOO_REQUIRE_SETUP_TOKEN' 'true' + Set-EnvValue 'VENDOO_COOKIE_SAMESITE' 'lax' + Set-EnvValue 'LOCAL_IMAGE_ENABLED' ($(if ($FluxEnabled) { 'true' } else { 'false' })) + + if ($Target -eq 'windows-local') { + Set-EnvValue 'VENDOO_BIND_HOST' ($(if ($Access -eq 'LAN') { '0.0.0.0' } else { '127.0.0.1' })) + Set-EnvValue 'VENDOO_PUBLISH_ADDRESS' '127.0.0.1' + Set-EnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-EnvValue 'VENDOO_TRUST_PROXY' 'false' + Set-EnvValue 'LOCAL_IMAGE_URL' 'http://127.0.0.1:8188' + } else { + Set-EnvValue 'VENDOO_BIND_HOST' '0.0.0.0' + Set-EnvValue 'VENDOO_PUBLISH_ADDRESS' ($(if ($Access -eq 'LAN') { '0.0.0.0' } else { '127.0.0.1' })) + Set-EnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-EnvValue 'VENDOO_TRUST_PROXY' 'false' + Set-EnvValue 'LOCAL_IMAGE_URL' 'http://host.docker.internal:8188' + } + + Set-EnvValue 'PUBLIC_BASE_URL' ($(if ([string]::IsNullOrWhiteSpace($PublicUrl)) { '' } else { $PublicUrl.Trim().TrimEnd('/') })) + + Write-Utf8NoBom -Path $ModeFile -Content ($Target + "`n") + try { [System.Windows.Forms.Clipboard]::SetText($token) } catch {} + return $token +} + +$form = New-Object System.Windows.Forms.Form +$form.Text = 'Vendoo 1.36.0 – Installation' +$form.StartPosition = 'CenterScreen' +$form.Size = New-Object System.Drawing.Size(720, 620) +$form.MinimumSize = New-Object System.Drawing.Size(720, 620) +$form.Font = New-Object System.Drawing.Font('Segoe UI', 10) +$form.BackColor = [System.Drawing.Color]::FromArgb(246, 248, 247) + +$title = New-Object System.Windows.Forms.Label +$title.Text = 'Vendoo installieren' +$title.Font = New-Object System.Drawing.Font('Segoe UI Semibold', 22) +$title.AutoSize = $true +$title.Location = New-Object System.Drawing.Point(34, 24) +$form.Controls.Add($title) + +$sub = New-Object System.Windows.Forms.Label +$sub.Text = 'Alle Pflichtwerte werden geprüft und automatisch gespeichert. Es müssen keine Dateien bearbeitet werden.' +$sub.AutoSize = $false +$sub.Size = New-Object System.Drawing.Size(640, 48) +$sub.Location = New-Object System.Drawing.Point(38, 72) +$sub.ForeColor = [System.Drawing.Color]::FromArgb(70, 80, 76) +$form.Controls.Add($sub) + +function Add-Label([string]$Text, [int]$Y) { + $l = New-Object System.Windows.Forms.Label + $l.Text = $Text + $l.AutoSize = $true + $l.Location = New-Object System.Drawing.Point(40, $Y) + $form.Controls.Add($l) + return $l +} + +Add-Label 'Installationsart' 132 | Out-Null +$target = New-Object System.Windows.Forms.ComboBox +$target.DropDownStyle = 'DropDownList' +$target.Items.AddRange(@('Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC')) +$target.SelectedIndex = 0 +$target.Location = New-Object System.Drawing.Point(40, 158) +$target.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($target) + +Add-Label 'Erreichbarkeit' 208 | Out-Null +$access = New-Object System.Windows.Forms.ComboBox +$access.DropDownStyle = 'DropDownList' +$access.Items.AddRange(@('Nur auf diesem PC (empfohlen)', 'Im privaten LAN erreichbar')) +$access.SelectedIndex = 0 +$access.Location = New-Object System.Drawing.Point(40, 234) +$access.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($access) + +Add-Label 'Port' 284 | Out-Null +$portBox = New-Object System.Windows.Forms.NumericUpDown +$portBox.Minimum = 1024 +$portBox.Maximum = 65535 +$portBox.Value = 8124 +$portBox.Location = New-Object System.Drawing.Point(40, 310) +$portBox.Size = New-Object System.Drawing.Size(160, 34) +$form.Controls.Add($portBox) + +Add-Label 'Öffentliche/LAN-Basisadresse (optional)' 358 | Out-Null +$publicUrl = New-Object System.Windows.Forms.TextBox +$publicUrl.Location = New-Object System.Drawing.Point(40, 384) +$publicUrl.Size = New-Object System.Drawing.Size(620, 34) +$form.Controls.Add($publicUrl) + +$flux = New-Object System.Windows.Forms.CheckBox +$flux.Text = 'Lokales FLUX Studio aktivieren (ComfyUI bleibt ein getrennter Dienst)' +$flux.AutoSize = $true +$flux.Location = New-Object System.Drawing.Point(40, 438) +$form.Controls.Add($flux) + +$hint = New-Object System.Windows.Forms.Label +$hint.Text = 'AI- und Marktplatz-Schlüssel werden später bequem unter Vendoo → Einstellungen eingetragen.' +$hint.AutoSize = $false +$hint.Size = New-Object System.Drawing.Size(620, 42) +$hint.Location = New-Object System.Drawing.Point(40, 474) +$hint.ForeColor = [System.Drawing.Color]::FromArgb(70, 80, 76) +$form.Controls.Add($hint) + +$install = New-Object System.Windows.Forms.Button +$install.Text = 'Installieren' +$install.Size = New-Object System.Drawing.Size(180, 44) +$install.Location = New-Object System.Drawing.Point(480, 520) +$install.BackColor = [System.Drawing.Color]::FromArgb(32, 112, 78) +$install.ForeColor = [System.Drawing.Color]::White +$install.FlatStyle = 'Flat' +$form.Controls.Add($install) + +$advanced = New-Object System.Windows.Forms.Button +$advanced.Text = 'Konsolenmenü' +$advanced.Size = New-Object System.Drawing.Size(150, 44) +$advanced.Location = New-Object System.Drawing.Point(40, 520) +$form.Controls.Add($advanced) + +$cancel = New-Object System.Windows.Forms.Button +$cancel.Text = 'Abbrechen' +$cancel.Size = New-Object System.Drawing.Size(130, 44) +$cancel.Location = New-Object System.Drawing.Point(330, 520) +$form.Controls.Add($cancel) + +$target.Add_SelectedIndexChanged({ + if ($target.SelectedIndex -eq 1) { + $hint.Text = 'Docker Desktop muss gestartet sein. AI- und Marktplatz-Schlüssel werden später in Vendoo gepflegt.' + } else { + $hint.Text = 'AI- und Marktplatz-Schlüssel werden später bequem unter Vendoo → Einstellungen eingetragen.' + } +}) + +$advanced.Add_Click({ + $form.Hide() + Start-Process powershell.exe -ArgumentList @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $SetupScript + '"'),'menu') -Wait + $form.Close() +}) + +$cancel.Add_Click({ $form.Close() }) + +$install.Add_Click({ + try { + $install.Enabled = $false + $targetMode = if ($target.SelectedIndex -eq 1) { 'docker' } else { 'windows-local' } + $accessMode = if ($access.SelectedIndex -eq 1) { 'LAN' } else { 'LOCAL' } + $url = $publicUrl.Text.Trim() + if ($url -and $url -notmatch '^https?://') { throw 'Die Basisadresse muss mit http:// oder https:// beginnen.' } + if ($accessMode -eq 'LAN' -and [string]::IsNullOrWhiteSpace($url)) { + $answer = [System.Windows.Forms.MessageBox]::Show('Für QR-Uploads ist später eine LAN-Adresse sinnvoll. Trotzdem ohne Basisadresse fortfahren?', 'LAN-Adresse fehlt', 'YesNo', 'Warning') + if ($answer -ne 'Yes') { return } + } + $token = Set-Configuration -Target $targetMode -Access $accessMode -Port ([int]$portBox.Value) -PublicUrl $url -FluxEnabled $flux.Checked + [System.Windows.Forms.MessageBox]::Show("Die Konfiguration ist gespeichert. Der Erst-Admin-Code wurde in die Zwischenablage kopiert.`r`n`r`n$token`r`n`r`nBewahre ihn bis zur ersten Anmeldung sicher auf.", 'Vendoo vorbereitet', 'OK', 'Information') | Out-Null + $form.Hide() + $command = if ($targetMode -eq 'docker') { 'install-docker' } else { 'install-local' } + $proc = Start-Process powershell.exe -ArgumentList @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $SetupScript + '"'),$command) -Wait -PassThru + if ($proc.ExitCode -eq 0) { + Start-Process ("http://127.0.0.1:{0}/login.html" -f [int]$portBox.Value) + } else { + [System.Windows.Forms.MessageBox]::Show("Die Installation wurde mit Exit-Code $($proc.ExitCode) beendet. Das Konsolenfenster enthält die Details.", 'Installation nicht abgeschlossen', 'OK', 'Error') | Out-Null + } + $form.Close() + } catch { + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Eingabe prüfen', 'OK', 'Warning') | Out-Null + } finally { + $install.Enabled = $true + } +}) + +[void]$form.ShowDialog() diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..12fb400 --- /dev/null +++ b/setup.bat @@ -0,0 +1,47 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +title Vendoo Setup +cd /d "%~dp0" + +if not exist "%~dp0setup.ps1" ( + echo. + echo [FEHLER] setup.ps1 wurde nicht gefunden: + echo %~dp0setup.ps1 + echo. + pause + exit /b 2 +) + +set "VENDOO_SETUP_PS1=%~dp0setup.ps1" +set "VENDOO_SETUP_GUI=%~dp0setup-gui.ps1" + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$files=@($env:VENDOO_SETUP_PS1); if(Test-Path -LiteralPath $env:VENDOO_SETUP_GUI){$files += $env:VENDOO_SETUP_GUI}; foreach($file in $files){$tokens=$null; $errors=$null; [System.Management.Automation.Language.Parser]::ParseFile($file,[ref]$tokens,[ref]$errors) | Out-Null; if($errors.Count -gt 0){$errors | ForEach-Object { Write-Host ('[PARSER] ' + $file + ': ' + $_.Message) -ForegroundColor Red }; exit 12}}" +if errorlevel 1 ( + echo. + echo [FEHLER] Die PowerShell-Syntax des Installers ist ungueltig. + echo Vendoo Setup wurde aus Sicherheitsgruenden nicht gestartet. + echo. + pause + exit /b 12 +) + +if "%~1"=="" ( + if exist "%VENDOO_SETUP_GUI%" ( + powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%VENDOO_SETUP_GUI%" + exit /b %ERRORLEVEL% + ) +) + +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%VENDOO_SETUP_PS1%" %* +set "VENDOO_EXIT=%ERRORLEVEL%" + +if not "%VENDOO_EXIT%"=="0" ( + echo. + echo [FEHLER] Vendoo Setup wurde mit Exit-Code %VENDOO_EXIT% beendet. + echo Die Protokolle befinden sich im Ordner "%~dp0logs". + echo. +) + +pause +exit /b %VENDOO_EXIT% diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..e34667a --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,1738 @@ +$ErrorActionPreference = 'Stop' +$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Version = '1.36.0' +$Port = 8124 +$InstallModeFile = Join-Path $VendooDir '.vendoo-install-mode' + +try { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [Console]::InputEncoding = $utf8NoBom + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch {} + + +function Write-Utf8NoBomFile([string]$Path, [string]$Content) { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8) +} + +function Write-Header { + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════╗' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ║ V E N D O O S E T U P ║' -ForegroundColor Green + Write-Host ' ║ Listing-Generator v'$Version' ║' -ForegroundColor DarkGreen + Write-Host ' ║ ║' -ForegroundColor DarkGreen + Write-Host ' ╚══════════════════════════════════════╝' -ForegroundColor DarkGreen + Write-Host '' +} + +function Write-Step($step, $text) { + Write-Host " [$step] " -ForegroundColor Cyan -NoNewline + Write-Host $text +} + +function Write-OK($text) { + Write-Host " [OK] " -ForegroundColor Green -NoNewline + Write-Host $text +} + +function Write-Warn($text) { + Write-Host " [!] " -ForegroundColor Yellow -NoNewline + Write-Host $text +} + +function Write-Err($text) { + Write-Host " [X] " -ForegroundColor Red -NoNewline + Write-Host $text +} + +function Remove-LegacyTryOnComponents { + $migrationMarker = Join-Path $VendooDir '.migration-image-stack-cleanup-1.20.0' + if (Test-Path -LiteralPath $migrationMarker) { return } + + $legacyRoot = Join-Path $VendooDir 'local-ai\fashn-vton' + $legacyUploadRoot = Join-Path $VendooDir 'uploads\vto-models' + $legacyArchiveRoot = Join-Path $VendooDir 'uploads\ai-generated\legacy-models' + $hasLegacyRuntime = (Test-Path -LiteralPath $legacyRoot) -or (Test-Path -LiteralPath $legacyUploadRoot) + if ($hasLegacyRuntime) { + Write-Host '' + Write-Step 'Cleanup' 'Entferne den aufgegebenen lokalen Try-on-Stack. Eigene Bilder bleiben erhalten ...' + } + + try { + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'fashn-vton|local-vto|service\.py.+8190' } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + } catch {} + try { & schtasks.exe /Delete /F /TN 'Vendoo FASHN VTON' 2>$null | Out-Null } catch {} + try { Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'VendooFashnVton' -ErrorAction SilentlyContinue } catch {} + try { + $startup = [Environment]::GetFolderPath('Startup') + if ($startup) { Remove-Item -LiteralPath (Join-Path $startup 'Vendoo-FASHN-VTON.cmd') -Force -ErrorAction SilentlyContinue } + } catch {} + + if (Test-Path -LiteralPath $legacyUploadRoot) { + Get-ChildItem -LiteralPath $legacyUploadRoot -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $relative = $_.FullName.Substring($legacyUploadRoot.Length).TrimStart('\') + $target = Join-Path $legacyArchiveRoot $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null + if (-not (Test-Path -LiteralPath $target)) { Move-Item -LiteralPath $_.FullName -Destination $target -Force -ErrorAction SilentlyContinue } + } + Remove-Item -LiteralPath $legacyUploadRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + if (Test-Path -LiteralPath $legacyRoot) { + Write-Step 'Cleanup' 'Entferne alte Python-, Modell- und Dienstdateien ...' + Remove-Item -LiteralPath $legacyRoot -Recurse -Force -ErrorAction SilentlyContinue + } + @( + 'scripts\install-local-vto.ps1','scripts\uninstall-local-vto.ps1', + 'lib\ai-vto-bfl.mjs','lib\ai-vto-local.mjs','lib\ai-vto-model-jobs.mjs','lib\ai-vto-model-prompts.mjs', + 'local-ai\workflows\vendoo_flux_tryon_api.json' + ) | ForEach-Object { Remove-Item -LiteralPath (Join-Path $VendooDir $_) -Force -ErrorAction SilentlyContinue } + Get-ChildItem -LiteralPath (Join-Path $VendooDir 'logs') -Filter 'local-vto*' -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + + $legacyEnv = Join-Path $VendooDir 'local-ai\.env.local-flux' + if (Test-Path -LiteralPath $legacyEnv) { + $cleanLines = Get-Content -LiteralPath $legacyEnv -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch '^LOCAL_IMAGE_TRYON_WORKFLOW_PATH=' } + Write-Utf8NoBomFile -Path $legacyEnv -Content (($cleanLines -join [Environment]::NewLine) + [Environment]::NewLine) + } + + $rootEnv = Join-Path $VendooDir '.env' + if (Test-Path -LiteralPath $rootEnv) { + $cleanRootEnv = Get-Content -LiteralPath $rootEnv -ErrorAction SilentlyContinue | Where-Object { + $_ -notmatch '^(BFL_API_KEY|LOCAL_IMAGE_TRYON_WORKFLOW_PATH|VENDOO_VTO_URL|VENDOO_VTO_TOKEN)=' + } + Write-Utf8NoBomFile -Path $rootEnv -Content (($cleanRootEnv -join [Environment]::NewLine) + [Environment]::NewLine) + } + + $legacyDocs = @( + 'SLICE_24_VIRTUAL_TRYON_PROMPT_POPUP.md','SLICE_24_1_TRYON_WORKFLOW_SELF_HEAL.md', + 'SLICE_25_PRODUCTIVE_VIRTUAL_TRYON.md','SLICE_25_SHA256.json','SLICE_25_VERIFICATION.json', + 'SLICE_26_LOCAL_FASHN_VTON_FLUX_PROMPT.md','SLICE_26_SHA256.json','SLICE_26_VERIFICATION.json', + 'SLICE_26_1_PYTHON_INSTALLER_LIVE_STATUS.md','SLICE_26_1_SHA256.json','SLICE_26_1_VERIFICATION.json', + 'SLICE_26_2_INSTALLER_ENCODING_PYTHON_DETECTION.md','SLICE_26_2_SHA256.json','SLICE_26_2_VERIFICATION.json', + 'SLICE_26_3_PORTABLE_PYTHON_RUNTIME.md','SLICE_26_3_SHA256.json','SLICE_26_3_VERIFICATION.json', + 'SLICE_26_4_UV_PYTHON_DETECTION.md','SLICE_26_4_SHA256.json','SLICE_26_4_VERIFICATION.json', + 'SLICE_26_5_NATIVE_PROCESS_RUNNER.md','SLICE_26_5_VERIFICATION.json', + 'SLICE_26_6_VTO_PIP_BOOTSTRAP_STATUS.md','SLICE_26_6_VERIFICATION.json', + 'SLICE_26_7_NPM_PUBLIC_REGISTRY_ATOMIC_REPAIR.md','SLICE_26_7_VERIFICATION.json', + 'SLICE_26_8_VTO_DEPENDENCY_INSTALLER.md','SLICE_26_8_VERIFICATION.json', + 'SLICE_26_9_VTO_UTF8_PARSER_AUTOSTART.md','SLICE_27_FLUX_MODEL_STUDIO_LIBRARY.md','SLICE_27_VERIFICATION.json' + ) + foreach ($doc in $legacyDocs) { Remove-Item -LiteralPath (Join-Path $VendooDir ('docs\' + $doc)) -Force -ErrorAction SilentlyContinue } + + Write-Utf8NoBomFile -Path $migrationMarker -Content "Vendoo 1.20.0 image-stack cleanup completed." + if ($hasLegacyRuntime) { Write-OK 'Alter Try-on-Stack wurde entfernt; FLUX bleibt vollständig erhalten.' } +} + + +function Test-VendooPowerShellFiles { + $relativeFiles = @( + 'scripts\install-local-flux.ps1', + 'scripts\uninstall-local-flux.ps1', + 'setup-gui.ps1' + ) + $failed = $false + foreach ($relative in $relativeFiles) { + $path = Join-Path $VendooDir $relative + if (-not (Test-Path -LiteralPath $path)) { + Write-Err ("Pflichtskript fehlt: {0}" -f $relative) + $failed = $true + continue + } + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + Write-Err ("PowerShell-Syntaxfehler in {0}" -f $relative) + foreach ($parseError in $errors) { + Write-Host (" Zeile {0}: {1}" -f $parseError.Extent.StartLineNumber, $parseError.Message) -ForegroundColor Red + } + $failed = $true + } + } + return (-not $failed) +} + +function Test-NodeInstalled { + try { + $v = & node --version 2>$null + if ($v -match '^v(\d+)') { + $major = [int]$Matches[1] + return @{ Installed = $true; Version = $v; Major = $major } + } + } catch {} + return @{ Installed = $false; Version = $null; Major = 0 } +} + +function Test-NpmInstalled { + try { + $v = & npm --version 2>$null + if ($v) { return $true } + } catch {} + return $false +} + +function Get-VendooInstallState { + $nodeModules = Test-Path (Join-Path $VendooDir 'node_modules') + $envFile = Test-Path (Join-Path $VendooDir '.env') + $dbFile = Test-Path (Join-Path $VendooDir 'db\vendoo.db') + $state = 'not-installed' + if ($nodeModules -and $envFile) { $state = 'installed' } + elseif ($nodeModules -or $envFile -or $dbFile) { $state = 'partial' } + return @{ State = $state; NodeModules = $nodeModules; EnvFile = $envFile; DbFile = $dbFile } +} + +function Get-LocalFluxScriptPath { + return Join-Path $VendooDir 'scripts\install-local-flux.ps1' +} + + +function Stop-VendooNodeProcesses { + $stopped = 0 + try { + $escapedDir = [regex]::Escape($VendooDir) + $processes = Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and $_.CommandLine -match $escapedDir } + foreach ($process in $processes) { + try { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + $stopped++ + } catch {} + } + } catch {} + if ($stopped -gt 0) { + Write-Warn "$stopped laufende Vendoo-Node-Prozess(e) beendet" + Start-Sleep -Milliseconds 750 + } +} + +function Remove-NodeModulesSafe { + $nm = Join-Path $VendooDir 'node_modules' + if (-not (Test-Path $nm)) { return $true } + + Stop-VendooNodeProcesses + Write-Step '...' 'Unvollstaendige node_modules werden bereinigt...' + + try { + Remove-Item $nm -Recurse -Force -Confirm:$false -ErrorAction Stop + } catch { + try { + & cmd.exe /d /c "rmdir /s /q `"$nm`"" | Out-Null + } catch {} + } + + if (Test-Path $nm) { + Write-Err 'node_modules konnte nicht vollstaendig entfernt werden.' + Write-Host ' Ursache ist meist ein noch laufender Node-Prozess, Virenscanner oder Explorer-Zugriff.' -ForegroundColor Yellow + Write-Host ' Bitte alle Vendoo-/Node-Fenster schliessen, notfalls Windows neu starten und Reparieren erneut ausfuehren.' -ForegroundColor Yellow + return $false + } + return $true +} + +function Repair-VendooNpmRegistryConfiguration { + $lockFile = Join-Path $VendooDir 'package-lock.json' + $npmrcFile = Join-Path $VendooDir '.npmrc' + $publicRegistry = 'https://registry.npmjs.org/' + + $npmrcContent = @( + 'registry=https://registry.npmjs.org/', + 'audit=false', + 'fund=false', + 'strict-ssl=true', + 'prefer-offline=true', + 'fetch-retries=5', + 'fetch-retry-factor=2', + 'fetch-retry-mintimeout=10000', + 'fetch-retry-maxtimeout=120000' + ) -join [Environment]::NewLine + Write-Utf8NoBomFile -Path $npmrcFile -Content ($npmrcContent + [Environment]::NewLine) + + if (-not (Test-Path -LiteralPath $lockFile)) { return $true } + try { + $raw = [System.IO.File]::ReadAllText($lockFile) + $original = $raw + $raw = [regex]::Replace( + $raw, + 'https://packages\.applied-caas-gateway\d+\.internal\.api\.openai\.org/artifactory/api/npm/npm-public/', + $publicRegistry, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if ($raw -match '(?i)internal\.api\.openai\.org|applied-caas-gateway|/artifactory/api/npm/') { + Write-Err 'package-lock.json enthält weiterhin eine nicht öffentliche Registry-URL.' + Write-Host ' Das Paket wurde aus einer Build-Umgebung verunreinigt. npm ci wird aus Sicherheitsgründen nicht gestartet.' -ForegroundColor Yellow + return $false + } + if ($raw -ne $original) { + Write-Warn 'Interne Build-Registry im package-lock.json wurde automatisch auf registry.npmjs.org korrigiert.' + Write-Utf8NoBomFile -Path $lockFile -Content $raw + } + return $true + } catch { + Write-Err ("package-lock.json konnte nicht geprüft werden: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-VendooNodeModulesBackupPath { + return (Join-Path $VendooDir 'node_modules.vendoo-backup') +} + +function Restore-VendooNodeModulesBackup { + $nodeModules = Join-Path $VendooDir 'node_modules' + $backup = Get-VendooNodeModulesBackupPath + if (-not (Test-Path -LiteralPath $backup)) { return } + try { + if (Test-Path -LiteralPath $nodeModules) { + Remove-Item -LiteralPath $nodeModules -Recurse -Force -ErrorAction SilentlyContinue + } + Move-Item -LiteralPath $backup -Destination $nodeModules -Force -ErrorAction Stop + Write-Warn 'Vorherige node_modules wurden nach dem fehlgeschlagenen npm-Lauf wiederhergestellt.' + } catch { + Write-Warn ("node_modules-Sicherung konnte nicht automatisch wiederhergestellt werden: {0}" -f $_.Exception.Message) + } +} + +function Prepare-VendooNodeModulesBackup { + $nodeModules = Join-Path $VendooDir 'node_modules' + $backup = Get-VendooNodeModulesBackupPath + Stop-VendooNodeProcesses + + if ((Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $nodeModules)) { + try { Move-Item -LiteralPath $backup -Destination $nodeModules -Force -ErrorAction Stop } catch {} + } + if (Test-Path -LiteralPath $backup) { + try { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } catch { + Write-Err 'Alte node_modules-Sicherung konnte nicht entfernt werden.' + return $false + } + } + if (Test-Path -LiteralPath $nodeModules) { + try { + Move-Item -LiteralPath $nodeModules -Destination $backup -Force -ErrorAction Stop + Write-Step '...' 'Bestehende node_modules wurden vorübergehend gesichert.' + } catch { + Write-Warn 'node_modules konnten nicht atomar gesichert werden. Vendoo versucht die robuste Bereinigung.' + if (-not (Remove-NodeModulesSafe)) { return $false } + } + } + return $true +} + +function Remove-VendooNodeModulesBackup { + $backup = Get-VendooNodeModulesBackupPath + if (Test-Path -LiteralPath $backup) { + try { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } catch { + Write-Warn 'Die alte node_modules-Sicherung konnte nicht gelöscht werden und kann später manuell entfernt werden.' + } + } +} + +function Invoke-VendooNpmInstall([switch]$ForceClean) { + $logDir = Join-Path $VendooDir 'logs' + if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $logFile = Join-Path $logDir 'last-npm-install.log' + $lockFile = Join-Path $VendooDir 'package-lock.json' + $command = if (Test-Path $lockFile) { 'ci' } else { 'install' } + + if (-not (Repair-VendooNpmRegistryConfiguration)) { return $false } + if (-not (Prepare-VendooNodeModulesBackup)) { return $false } + + $npmCommand = $null + try { $npmCommand = (Get-Command 'npm.cmd' -ErrorAction Stop).Source } catch {} + if ([string]::IsNullOrWhiteSpace([string]$npmCommand)) { + Restore-VendooNodeModulesBackup + Write-Err 'npm.cmd wurde nicht gefunden.' + return $false + } + + $attempt = 1 + $maxAttempts = 3 + while ($attempt -le $maxAttempts) { + $stdoutFile = Join-Path $logDir ('npm-install-{0}.stdout.log' -f $attempt) + $stderrFile = Join-Path $logDir ('npm-install-{0}.stderr.log' -f $attempt) + $runnerFile = Join-Path $logDir ('npm-install-{0}.cmd' -f $attempt) + Remove-Item -LiteralPath $stdoutFile,$stderrFile,$runnerFile -Force -ErrorAction SilentlyContinue + + $runnerContent = @( + '@echo off', + ('cd /d "{0}"' -f $VendooDir), + ('call "{0}" {1} --registry=https://registry.npmjs.org/ --no-audit --no-fund --loglevel=warn' -f $npmCommand, $command), + 'set "VENDOO_NPM_EXIT=%ERRORLEVEL%"', + 'exit /b %VENDOO_NPM_EXIT%' + ) -join "`r`n" + [System.IO.File]::WriteAllText($runnerFile, $runnerContent + "`r`n", [System.Text.Encoding]::ASCII) + + $process = $null + $output = @() + $exitCode = 1 + $npmTimedOut = $false + try { + $cmdArguments = ('/d /s /c ""{0}""' -f $runnerFile) + $process = Start-Process -FilePath $env:ComSpec -ArgumentList $cmdArguments -WorkingDirectory $VendooDir -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + $started = Get-Date + $lastShown = -1 + $npmTimeoutSeconds = 1800 + while (-not $process.HasExited) { + Start-Sleep -Seconds 1 + $process.Refresh() + $elapsed = [int]((Get-Date) - $started).TotalSeconds + if ($elapsed -ge $npmTimeoutSeconds) { + $npmTimedOut = $true + try { $process.Kill() } catch {} + break + } + if ($elapsed -ge 2 -and [Math]::Floor($elapsed / 2) -gt $lastShown) { + $lastShown = [Math]::Floor($elapsed / 2) + $consoleWidth = 118 + try { if ($Host.UI.RawUI.WindowSize.Width -gt 20) { $consoleWidth = [Math]::Max(40, $Host.UI.RawUI.WindowSize.Width - 1) } } catch {} + $statusText = (" npm $command läuft seit ${elapsed}s (Versuch $attempt/$maxAttempts)") + if ($statusText.Length -gt $consoleWidth) { $statusText = $statusText.Substring(0, $consoleWidth) } + Write-Host ("`r" + $statusText.PadRight($consoleWidth)) -NoNewline -ForegroundColor DarkGray + } + } + $process.WaitForExit() + $process.Refresh() + $consoleWidth = 118 + try { if ($Host.UI.RawUI.WindowSize.Width -gt 20) { $consoleWidth = [Math]::Max(40, $Host.UI.RawUI.WindowSize.Width - 1) } } catch {} + Write-Host ("`r" + (' ' * $consoleWidth) + "`r") -NoNewline + $exitCode = if ($npmTimedOut) { -2 } else { [int]$process.ExitCode } + + $stdoutLines = if (Test-Path -LiteralPath $stdoutFile) { @(Get-Content -LiteralPath $stdoutFile -ErrorAction SilentlyContinue) } else { @() } + $stderrLines = if (Test-Path -LiteralPath $stderrFile) { @(Get-Content -LiteralPath $stderrFile -ErrorAction SilentlyContinue) } else { @() } + $output = @($stdoutLines + $stderrLines) + if ($npmTimedOut) { $output += 'Vendoo hat npm nach 30 Minuten ohne Abschluss beendet.' } + + # Zweite Sicherheitsstufe: Ein echter npm-Fehler darf niemals als Exit-Code 0 durchrutschen. + $fatalOutput = @($output | Where-Object { ([string]$_) -match '(?i)^\s*npm\s+(error|ERR!)\b' }) + if ($exitCode -eq 0 -and $fatalOutput.Count -gt 0) { + $exitCode = 1 + $output += 'Vendoo-Sicherheitsprüfung: npm meldete Fehlertext trotz Exit-Code 0. Lauf wird als fehlgeschlagen gewertet.' + } + } catch { + $output = @($_.Exception.Message) + $exitCode = 1 + } + + $header = @( + ('Zeit: {0}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')), + ('Befehl: npm {0} --registry=https://registry.npmjs.org/ --no-audit --no-fund --loglevel=warn' -f $command), + ('Arbeitsordner: {0}' -f $VendooDir), + ('Versuch: {0}/{1}' -f $attempt, $maxAttempts), + ('Exit-Code: {0}' -f $exitCode), + '' + ) + Write-Utf8NoBomFile -Path $logFile -Content ((@($header + $output) -join [Environment]::NewLine) + [Environment]::NewLine) + + Remove-Item -LiteralPath $stdoutFile,$stderrFile,$runnerFile -Force -ErrorAction SilentlyContinue + + if ($exitCode -eq 0) { + $warnings = @($output | Where-Object { ([string]$_) -match '(?i)^npm\s+warn|deprecated' }) + if ($warnings.Count -gt 0) { + Write-Warn ("npm $command war erfolgreich, meldete aber $($warnings.Count) Warnung(en). Details: $logFile") + } + Remove-VendooNodeModulesBackup + Write-OK "Dependencies mit npm $command installiert" + return $true + } + + $text = ($output | Out-String) + $isNetworkError = $text -match '(?i)ETIMEDOUT|ECONNRESET|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|network request|502 Bad Gateway|503 Service Unavailable' + if ($attempt -lt $maxAttempts -and $isNetworkError) { + $waitSeconds = if ($attempt -eq 1) { 5 } else { 15 } + Write-Warn ("npm-Netzwerkfehler. Neuer Versuch in $waitSeconds Sekunden über registry.npmjs.org.") + if (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules')) { + Remove-Item -LiteralPath (Join-Path $VendooDir 'node_modules') -Recurse -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds $waitSeconds + $attempt++ + continue + } + + Write-Err "npm $command fehlgeschlagen (Exit-Code $exitCode)" + Write-Host " Vollständiges Protokoll: $logFile" -ForegroundColor Yellow + $lastLines = @($output | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Last 20) + foreach ($line in $lastLines) { Write-Host " $line" -ForegroundColor DarkYellow } + Restore-VendooNodeModulesBackup + return $false + } + + Restore-VendooNodeModulesBackup + return $false +} + + +function Get-VendooInstallMode { + if (Test-Path -LiteralPath $InstallModeFile) { + try { + $mode = (Get-Content -LiteralPath $InstallModeFile -Raw).Trim().ToLowerInvariant() + if (@('windows-local', 'docker') -contains $mode) { return $mode } + } catch {} + } + if (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules')) { return 'windows-local' } + return 'windows-local' +} + +function Set-VendooInstallMode([string]$Mode) { + Write-Utf8NoBomFile -Path $InstallModeFile -Content ($Mode.Trim().ToLowerInvariant() + "`n") +} + +function Test-DockerAvailable { + try { + $version = & docker version --format '{{.Server.Version}}' 2>$null + if (-not $version) { return @{ Available = $false; Reason = 'Docker Engine ist nicht erreichbar.' } } + & docker compose version 1>$null 2>$null + if ($LASTEXITCODE -ne 0) { return @{ Available = $false; Reason = 'Das Docker-Compose-Plugin fehlt.' } } + return @{ Available = $true; Version = $version; Reason = '' } + } catch { + return @{ Available = $false; Reason = 'Docker Desktop bzw. Docker Engine wurde nicht gefunden.' } + } +} + +function Get-SecureSetupToken { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Set-VendooEnvValue([string]$Key, [string]$Value) { + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path -LiteralPath $envFile)) { return } + $content = Get-Content -LiteralPath $envFile -Raw + $escaped = [regex]::Escape($Key) + if ($content -match "(?m)^$escaped=") { + $content = [regex]::Replace($content, "(?m)^$escaped=.*$", "$Key=$Value") + } else { + $content = $content.TrimEnd() + "`r`n$Key=$Value`r`n" + } + Write-Utf8NoBomFile -Path $envFile -Content $content +} + +function Get-VendooEnvValue([string]$Key) { + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path -LiteralPath $envFile)) { return '' } + $line = Get-Content -LiteralPath $envFile | Where-Object { $_ -match ('^' + [regex]::Escape($Key) + '=') } | Select-Object -Last 1 + if (-not $line) { return '' } + return ($line -replace ('^' + [regex]::Escape($Key) + '='), '').Trim() +} + +function Initialize-VendooEnvironment([string]$Mode = 'docker') { + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if (-not (Test-Path -LiteralPath $envFile)) { + if (-not (Test-Path -LiteralPath $envExample)) { throw '.env.example fehlt.' } + Copy-Item -LiteralPath $envExample -Destination $envFile + Write-OK '.env aus sicherer Vorlage erstellt' + } + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_SETUP_TOKEN'))) { + $token = Get-SecureSetupToken + Set-VendooEnvValue 'VENDOO_SETUP_TOKEN' $token + Write-Host '' + Write-Host ' ERST-ADMIN-SETUP-CODE:' -ForegroundColor Yellow + Write-Host " $token" -ForegroundColor White + Write-Warn 'Diesen Code jetzt sicher notieren. Er wird bei der ersten Einrichtung benötigt.' + } + if ($Mode -eq 'docker') { + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS'))) { Set-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS' '127.0.0.1' } + Set-VendooEnvValue 'VENDOO_BIND_HOST' '0.0.0.0' + Set-VendooEnvValue 'LOCAL_IMAGE_URL' 'http://host.docker.internal:8188' + } else { + if ([string]::IsNullOrWhiteSpace((Get-VendooEnvValue 'VENDOO_BIND_HOST'))) { Set-VendooEnvValue 'VENDOO_BIND_HOST' '127.0.0.1' } + Set-VendooEnvValue 'LOCAL_IMAGE_URL' 'http://127.0.0.1:8188' + } + Set-VendooEnvValue 'VENDOO_COOKIE_SECURE' 'auto' + Set-VendooEnvValue 'VENDOO_REQUIRE_SETUP_TOKEN' 'true' +} + +function Invoke-VendooDockerCompose([string[]]$Arguments) { + Push-Location $VendooDir + try { + $composeArgs = @('compose', '-f', (Join-Path $VendooDir 'compose.yaml')) + $Arguments + & docker @composeArgs + return $LASTEXITCODE + } finally { Pop-Location } +} + +function Install-VendooDocker { + Write-Header + Write-Host ' Docker-Installation auf diesem Windows-PC ...' -ForegroundColor White + Write-Host '' + $docker = Test-DockerAvailable + if (-not $docker.Available) { + Write-Err $docker.Reason + Write-Host ' Installiere bzw. starte Docker Desktop und führe den Installer erneut aus.' -ForegroundColor Yellow + Write-Host ' Docker Desktop enthält Docker Engine, CLI und Docker Compose.' -ForegroundColor DarkGray + return + } + Write-OK "Docker Engine $($docker.Version) erreichbar" + Initialize-VendooEnvironment -Mode 'docker' + Set-VendooInstallMode 'docker' + Write-Step '1/3' 'Compose-Konfiguration prüfen ...' + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + Write-Step '2/3' 'Vendoo-Container bauen ...' + if ((Invoke-VendooDockerCompose @('build', '--pull')) -ne 0) { Write-Err 'Docker-Build fehlgeschlagen.'; return } + Write-Step '3/3' 'Vendoo starten ...' + if ((Invoke-VendooDockerCompose @('up', '-d', '--remove-orphans')) -ne 0) { Write-Err 'Containerstart fehlgeschlagen.'; return } + Write-OK 'Vendoo läuft im Docker-Modus.' + Write-Host " Readiness: http://127.0.0.1:$Port/readyz" -ForegroundColor Cyan + Start-Process "http://127.0.0.1:$Port/login.html" +} + +function Show-VendooRemoteDeploymentGuide([string]$Target) { + Write-Header + Write-Host " $Target-Installation" -ForegroundColor White + Write-Host '' + Write-Host ' Ein VPS oder NAS wird nicht unsicher vom Windows-PC ferninstalliert.' -ForegroundColor Yellow + Write-Host ' Kopiere den Vendoo-Ordner auf das Zielsystem und starte dort:' -ForegroundColor White + Write-Host '' + Write-Host ' chmod +x setup.sh' -ForegroundColor Cyan + Write-Host ' ./setup.sh' -ForegroundColor Cyan + Write-Host '' + Write-Host ' Der Linux-Assistent bietet Docker, NAS/Portainer und VPS hinter HTTPS-Reverse-Proxy an.' -ForegroundColor DarkGray + Write-Host ' Datenbank, Uploads, .env, Backups und Operations bleiben persistent und werden nicht gelöscht.' -ForegroundColor DarkGray +} + +function Configure-VendooConsole { + Write-Header + Write-Host ' Geführte Konfiguration – keine Datei muss manuell bearbeitet werden.' -ForegroundColor White + Write-Host '' + $mode = Get-VendooInstallMode + $portInput = Read-Host " Port [$Port]" + if (-not [string]::IsNullOrWhiteSpace($portInput)) { + $parsed = 0 + if (-not [int]::TryParse($portInput, [ref]$parsed) -or $parsed -lt 1024 -or $parsed -gt 65535) { Write-Err 'Port muss zwischen 1024 und 65535 liegen.'; return } + $script:Port = $parsed + } + Initialize-VendooEnvironment -Mode ($(if ($mode -eq 'docker') { 'docker' } else { 'windows-local' })) + Set-VendooEnvValue 'PORT' ([string]$Port) + Set-VendooEnvValue 'VENDOO_HOST_PORT' ([string]$Port) + $lan = Read-Host ' Zugriff im privaten LAN erlauben? (ja/nein)' + if ($mode -eq 'docker') { + Set-VendooEnvValue 'VENDOO_PUBLISH_ADDRESS' ($(if ($lan -eq 'ja') { '0.0.0.0' } else { '127.0.0.1' })) + } else { + Set-VendooEnvValue 'VENDOO_BIND_HOST' ($(if ($lan -eq 'ja') { '0.0.0.0' } else { '127.0.0.1' })) + } + if ($lan -eq 'ja') { + $baseUrl = Read-Host ' LAN-Basisadresse für QR-Uploads (optional, z. B. http://192.168.1.20:8124)' + if (-not [string]::IsNullOrWhiteSpace($baseUrl)) { + if ($baseUrl -notmatch '^https?://') { Write-Err 'Adresse muss mit http:// oder https:// beginnen.'; return } + Set-VendooEnvValue 'PUBLIC_BASE_URL' $baseUrl.Trim().TrimEnd('/') + } + } + $flux = Read-Host ' Lokales FLUX aktivieren? (ja/nein)' + Set-VendooEnvValue 'LOCAL_IMAGE_ENABLED' ($(if ($flux -eq 'ja') { 'true' } else { 'false' })) + $token = Get-VendooEnvValue 'VENDOO_SETUP_TOKEN' + try { Set-Clipboard -Value $token -ErrorAction Stop; Write-OK 'Erst-Admin-Code wurde in die Zwischenablage kopiert.' } catch {} + Write-OK 'Konfiguration gespeichert.' + Write-Host ' AI- und Marktplatz-Schlüssel werden später in Vendoo → Einstellungen gepflegt.' -ForegroundColor DarkGray +} + +function Install-Vendoo { + Write-Header + Write-Host ' Wo soll Vendoo betrieben werden?' -ForegroundColor White + Write-Host '' + Write-Host ' 1 Lokal auf diesem Windows-PC (Node.js)' -ForegroundColor Cyan + Write-Host ' 2 Docker Desktop auf diesem Windows-PC' -ForegroundColor Cyan + Write-Host ' 3 NAS / Portainer / Docker-Server' -ForegroundColor Cyan + Write-Host ' 4 VPS / öffentlicher Linux-Server' -ForegroundColor Cyan + Write-Host ' 0 Abbrechen' -ForegroundColor DarkGray + Write-Host '' + $choice = Read-Host ' Installationsziel' + switch ($choice) { + '1' { Set-VendooInstallMode 'windows-local'; Install-VendooLocal } + '2' { Install-VendooDocker } + '3' { Show-VendooRemoteDeploymentGuide 'NAS / Portainer' } + '4' { Show-VendooRemoteDeploymentGuide 'VPS / Linux' } + '0' { return } + default { Write-Warn 'Ungültiges Installationsziel.' } + } +} + +function Start-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + if ((Invoke-VendooDockerCompose @('up', '-d')) -eq 0) { + Write-OK 'Vendoo-Container gestartet.' + Start-Process "http://127.0.0.1:$Port/" + } + return + } + Start-VendooLocal +} + +function Update-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Initialize-VendooEnvironment -Mode 'docker' + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + if ((Invoke-VendooDockerCompose @('build', '--pull')) -ne 0) { Write-Err 'Docker-Update-Build fehlgeschlagen.'; return } + if ((Invoke-VendooDockerCompose @('up', '-d', '--remove-orphans')) -ne 0) { Write-Err 'Docker-Update konnte nicht gestartet werden.'; return } + Write-OK 'Docker-Update abgeschlossen.' + return + } + Update-VendooLocal +} + +function Repair-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + if ((Invoke-VendooDockerCompose @('config')) -ne 0) { Write-Err 'Compose-Konfiguration ist ungültig.'; return } + if ((Invoke-VendooDockerCompose @('build', '--no-cache')) -ne 0) { Write-Err 'Docker-Reparatur-Build fehlgeschlagen.'; return } + if ((Invoke-VendooDockerCompose @('up', '-d', '--force-recreate', '--remove-orphans')) -ne 0) { Write-Err 'Docker-Reparaturstart fehlgeschlagen.'; return } + Write-OK 'Docker-Container neu gebaut und gestartet.' + return + } + Repair-VendooLocal +} + +function Install-VendooLocal { + Write-Header + Write-Host ' Installation starten...' -ForegroundColor White + Write-Host '' + + $installState = Get-VendooInstallState + if ($installState.State -eq 'installed') { + Write-Warn 'Vendoo scheint bereits installiert zu sein.' + Write-Host ' Statt versehentlich neu zu installieren, nutze besser Starten, Update oder Reparieren.' -ForegroundColor Yellow + $choice = Read-Host ' Aktion: [S]tarten / [U]pdate / [R]eparieren / [N]eu installieren / [A]bbrechen' + if ([string]::IsNullOrWhiteSpace($choice)) { + $choice = '' + } + $choice = $choice.ToLowerInvariant() + switch ($choice) { + 's' { Start-VendooLocal; return } + 'u' { Update-VendooLocal; return } + 'r' { Repair-VendooLocal; return } + 'a' { return } + 'n' { Write-Host ' Vollständige Neuinstallation wird fortgesetzt...' -ForegroundColor DarkGray } + default { + Write-Warn 'Keine gültige Aktion gewählt. Installation wurde abgebrochen.' + return + } + } + } + + # 1. Node.js pruefen + Write-Step '1/5' 'Node.js pruefen...' + $node = Test-NodeInstalled + if (-not $node.Installed) { + Write-Err 'Node.js ist nicht installiert!' + Write-Host '' + Write-Host ' Node.js wird benoetigt (mindestens v18).' -ForegroundColor Yellow + Write-Host ' Download: https://nodejs.org/' -ForegroundColor Cyan + Write-Host '' + $dl = Read-Host ' Node.js jetzt automatisch installieren? (j/n)' + if ($dl -eq 'j') { + Write-Step '...' 'Lade Node.js Installer herunter...' + $installerUrl = 'https://nodejs.org/dist/v22.16.0/node-v22.16.0-x64.msi' + $installerPath = Join-Path $env:TEMP 'node-installer.msi' + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing + Write-Step '...' 'Installiere Node.js...' + Start-Process msiexec.exe -ArgumentList "/i `"$installerPath`" /qn" -Wait -NoNewWindow + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + Start-Sleep -Seconds 2 + $node = Test-NodeInstalled + if ($node.Installed) { + Write-OK "Node.js $($node.Version) installiert" + } else { + Write-Err 'Installation fehlgeschlagen. Bitte manuell installieren.' + return + } + } catch { + Write-Err "Download fehlgeschlagen: $_" + Write-Host ' Bitte installiere Node.js manuell: https://nodejs.org/' -ForegroundColor Yellow + return + } + } else { + Write-Host ' Installation abgebrochen.' -ForegroundColor Yellow + return + } + } elseif ($node.Major -lt 18) { + Write-Warn "Node.js $($node.Version) ist zu alt. Mindestens v18 erforderlich." + Write-Host ' Update: https://nodejs.org/' -ForegroundColor Cyan + return + } else { + Write-OK "Node.js $($node.Version) gefunden" + } + + # 2. npm pruefen + Write-Step '2/5' 'npm pruefen...' + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV gefunden" + } else { + Write-Err 'npm nicht gefunden. Wird mit Node.js mitgeliefert.' + return + } + + # 3. Dependencies reproduzierbar installieren + Write-Step '3/5' 'Dependencies installieren (npm ci)...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # 4. Sichere Konfiguration erzeugen bzw. übernehmen + Write-Step '4/5' 'Sichere Konfiguration prüfen...' + Initialize-VendooEnvironment -Mode 'windows-local' + Write-OK 'Konfiguration ist vollständig; keine manuelle Dateibearbeitung erforderlich' + + # 5. Ordner pruefen + Write-Step '5/5' 'Verzeichnisse pruefen...' + $dirs = @('uploads', 'db') + foreach ($d in $dirs) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { + New-Item -ItemType Directory -Path $p -Force | Out-Null + } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' ========================================' -ForegroundColor Green + Write-Host ' Installation abgeschlossen!' -ForegroundColor Green + Write-Host ' ========================================' -ForegroundColor Green + Write-Host '' + Write-Host " Starte Vendoo mit: " -NoNewline + Write-Host 'npm start' -ForegroundColor Cyan + Write-Host " Oder im Dev-Modus: " -NoNewline + Write-Host 'npm run dev' -ForegroundColor Cyan + Write-Host " Dann oeffne: " -NoNewline + Write-Host "http://localhost:$Port" -ForegroundColor Cyan + Write-Host '' + + if ($script:AutoStartAfterInstall) { + Start-VendooLocalDetached + return + } + $start = Read-Host ' Vendoo jetzt starten? (j/n)' + if ($start -eq 'j') { + Start-VendooLocal + } +} + +function Start-VendooLocalDetached { + Write-Step '...' 'Starte Vendoo im eigenen Prozess ...' + Start-Process -FilePath 'node.exe' -ArgumentList @('bootstrap.mjs') -WorkingDirectory $VendooDir -WindowStyle Minimized + Start-Sleep -Seconds 2 + Write-OK 'Vendoo wurde gestartet.' +} + +function Start-VendooLocal { + Write-Host '' + Write-Step '...' 'Starte Vendoo-Server...' + Push-Location $VendooDir + Start-Process "http://localhost:$Port/?build=1.36.0" + & node bootstrap.mjs + Pop-Location +} + +function Update-VendooLocal { + Write-Header + Write-Host ' Update durchfuehren...' -ForegroundColor White + Write-Host '' + + # Dependencies reproduzierbar aktualisieren + Write-Step '1/2' 'Dependencies aktualisieren...' + if (-not (Invoke-VendooNpmInstall)) { return } + + # Neue .env Eintraege pruefen + Write-Step '2/2' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + $envExample = Join-Path $VendooDir '.env.example' + if ((Test-Path $envFile) -and (Test-Path $envExample)) { + $current = Get-Content $envFile -Raw + $example = Get-Content $envExample + $added = 0 + foreach ($line in $example) { + if ($line -match '^([A-Z_]+)=' -and $current -notmatch "(?m)^$($Matches[1])=") { + Add-Content $envFile "`n$line" + $added++ + } + } + if ($added -gt 0) { + Write-OK "$added neue Konfigurationseintraege hinzugefuegt" + } else { + Write-OK '.env ist aktuell' + } + } + + Write-Host '' + Write-Host ' Update abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Repair-VendooLocal { + Write-Header + Write-Host ' Reparatur durchfuehren...' -ForegroundColor White + Write-Host '' + + # 1. node_modules robust loeschen und reproduzierbar neu installieren + Write-Step '1/3' 'node_modules neu installieren...' + if (-not (Invoke-VendooNpmInstall -ForceClean)) { return } + + # 2. .env pruefen + Write-Step '2/3' '.env pruefen...' + $envFile = Join-Path $VendooDir '.env' + if (-not (Test-Path $envFile)) { + $envExample = Join-Path $VendooDir '.env.example' + if (Test-Path $envExample) { Copy-Item $envExample $envFile } + Write-Warn '.env fehlte und wurde neu erstellt' + } else { + Write-OK '.env vorhanden' + } + + # 3. Ordner pruefen + Write-Step '3/3' 'Verzeichnisse pruefen...' + foreach ($d in @('uploads', 'db')) { + $p = Join-Path $VendooDir $d + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } + } + Write-OK 'Verzeichnisse OK' + + Write-Host '' + Write-Host ' Reparatur abgeschlossen!' -ForegroundColor Green + Write-Host '' +} + +function Uninstall-VendooLocal { + Write-Header + Write-Host ' Vendoo deinstallieren' -ForegroundColor Red + Write-Host '' + Write-Warn 'Dies entfernt node_modules und optionale Daten.' + Write-Host '' + + $confirm = Read-Host ' Wirklich deinstallieren? (ja/nein)' + if ($confirm -ne 'ja') { + Write-Host ' Abgebrochen.' -ForegroundColor Yellow + return + } + + $keepData = Read-Host ' Datenbank und Uploads behalten? (j/n)' + + Write-Step '1/2' 'node_modules entfernen...' + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { + Remove-Item $nm -Recurse -Force -Confirm:$false + Write-OK 'node_modules entfernt' + } else { + Write-OK 'node_modules nicht vorhanden' + } + + if ($keepData -ne 'j') { + Write-Step '2/2' 'Daten entfernen...' + $dbDir = Join-Path $VendooDir 'db' + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $dbDir) { + Remove-Item (Join-Path $dbDir '*.db'), (Join-Path $dbDir '*.db-shm'), (Join-Path $dbDir '*.db-wal') -Force -ErrorAction SilentlyContinue + Write-OK 'Datenbank entfernt' + } + if (Test-Path $uploadsDir) { + Get-ChildItem $uploadsDir -File | Remove-Item -Force -Confirm:$false + Write-OK 'Uploads entfernt' + } + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { + Remove-Item $envFile -Force -Confirm:$false + Write-OK '.env entfernt' + } + } else { + Write-OK 'Datenbank und Uploads beibehalten' + } + + Write-Host '' + Write-Host ' Vendoo wurde deinstalliert.' -ForegroundColor Green + Write-Host ' Die Projektdateien bleiben erhalten.' -ForegroundColor DarkGray + Write-Host ' Zum erneuten Installieren: setup.bat ausfuehren.' -ForegroundColor DarkGray + Write-Host '' +} + +function Install-LocalFlux { + Write-Header + Write-Host ' Lokales FLUX / ComfyUI installieren' -ForegroundColor White + Write-Host '' + $script = Get-LocalFluxScriptPath + if (-not (Test-Path $script)) { + Write-Err 'Installationsskript fuer lokales FLUX wurde nicht gefunden.' + return + } + + $stateFile = Join-Path $VendooDir 'local-ai\install-state.json' + $existingInstall = Test-Path (Join-Path $VendooDir 'local-ai\comfyui\ComfyUI_windows_portable\ComfyUI\main.py') + if ($existingInstall) { + Write-OK 'ComfyUI ist bereits installiert.' + $existingChoice = Read-Host ' [S]tatus / [U]pdate / [M]odell nachinstallieren / [N]eu installieren / [A]bbrechen' + if ([string]::IsNullOrWhiteSpace($existingChoice)) { return } + $existingChoice = $existingChoice.ToLowerInvariant() + switch ($existingChoice) { + 's' { Show-LocalFluxStatus; return } + 'u' { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -UpdateOnly -StartNow + return + } + 'm' { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -InstallModel -StartNow + return + } + 'n' { } + default { return } + } + } + + Write-Host ' Hinweis: Das FLUX-Modell ist sehr gross und kann lange herunterladen.' -ForegroundColor Yellow + $modelChoice = Read-Host ' FLUX.1-schnell FP8 direkt mitinstallieren? (j/n)' + $autoChoice = Read-Host ' Lokalen Bildserver bei Windows-Anmeldung automatisch starten? (j/n)' + $startChoice = Read-Host ' Nach Installation sofort starten? (j/n)' + + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script) + if ($modelChoice -eq 'j') { $arguments += '-InstallModel' } + if ($autoChoice -eq 'j') { $arguments += '-EnableAutostart' } + if ($startChoice -eq 'j') { $arguments += '-StartNow' } + if ($existingInstall) { $arguments += '-Force' } + + Write-Step '1/1' 'Starte automatischen lokalen FLUX-Installer...' + & powershell.exe @arguments + if ($LASTEXITCODE -eq 0) { + Write-OK 'Lokales FLUX / ComfyUI wurde eingerichtet.' + } else { + Write-Err 'Lokale FLUX-Installation ist fehlgeschlagen.' + if (Test-Path -LiteralPath $stateFile) { + try { + $failedState = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + Write-Host " Phase: $($failedState.phase)" -ForegroundColor Yellow + Write-Host " Fortschritt: $($failedState.progress)%" -ForegroundColor Yellow + Write-Host " Fehler: $($failedState.message)" -ForegroundColor Red + } catch {} + Write-Host " Statusdatei: $stateFile" -ForegroundColor Yellow + } + Write-Host " Log: $(Join-Path $VendooDir 'logs\local-flux-install.log')" -ForegroundColor Yellow + Write-Host ' Option 7 kann nach dem Fix erneut ausgeführt werden; vorhandene vollständige Downloads werden weiterverwendet.' -ForegroundColor DarkGray + } + Write-Host '' +} + + +function Get-LocalFluxStartupRecovery { + $stateFile = Join-Path $VendooDir 'local-ai\install-state.json' + $extractRoot = Join-Path $VendooDir 'local-ai\extract-temp' + $stdoutFile = Join-Path $VendooDir 'logs\local-flux-7zip-output.log' + $stderrFile = Join-Path $VendooDir 'logs\local-flux-7zip-error.log' + $portableRoot = Join-Path $VendooDir 'local-ai\comfyui\ComfyUI_windows_portable' + $finalMain = Join-Path $portableRoot 'ComfyUI\main.py' + $embeddedPython = Join-Path $portableRoot 'python_embeded\python.exe' + $modelPath = Join-Path $portableRoot 'ComfyUI\models\checkpoints\flux1-schnell-fp8.safetensors' + $partialModel = "$modelPath.part" + + if (-not (Test-Path -LiteralPath $stateFile)) { return $null } + + try { + $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + } catch { + return $null + } + + try { + $active = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine -match [regex]::Escape($VendooDir) -and + ($_.Name -match '^(7z|7zG|powershell)\.exe$') -and + $_.CommandLine -match 'install-local-flux|extract-temp|ComfyUI_windows_portable|flux1-schnell' + }) + if ($active.Count -gt 0) { return $null } + } catch {} + + # Bekannter Slice-22-Fehlalarm nach erfolgreicher 7-Zip-Entpackung. + if ((Test-Path -LiteralPath $extractRoot) -and -not (Test-Path -LiteralPath $finalMain)) { + $knownFalseFailure = ( + $state.status -eq 'failed' -and + [bool]$state.can_resume -and + [string]$state.message -match '7-Zip Exit-Code\s*\)' -and + [string]$state.message -match 'Everything is Ok' + ) + + if ($knownFalseFailure) { + $stderrText = '' + if (Test-Path -LiteralPath $stderrFile) { + try { $stderrText = (Get-Content -LiteralPath $stderrFile -Raw -ErrorAction Stop).Trim() } catch {} + } + + $stdoutText = '' + if (Test-Path -LiteralPath $stdoutFile) { + try { $stdoutText = Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction Stop } catch {} + } + + if ([string]::IsNullOrWhiteSpace($stderrText) -and $stdoutText -match '(?im)^Everything is Ok\s*$') { + $portable = Get-ChildItem -LiteralPath $extractRoot -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match '^ComfyUI_windows_portable' -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) -and + (Test-Path -LiteralPath (Join-Path $_.FullName 'python_embeded\python.exe')) + } | + Select-Object -First 1 + + if ($portable) { + return @{ + Mode = 'extract' + State = $state + PortablePath = $portable.FullName + ExtractRoot = $extractRoot + } + } + } + } + } + + # Unterbrochener oder scheinbar hängender Modell-Download. + $comfyReady = (Test-Path -LiteralPath $finalMain) -and (Test-Path -LiteralPath $embeddedPython) + $modelReady = $false + if (Test-Path -LiteralPath $modelPath) { + try { $modelReady = (Get-Item -LiteralPath $modelPath).Length -ge 1GB } catch {} + } + + $partialBytes = 0L + if (Test-Path -LiteralPath $partialModel) { + try { $partialBytes = [long](Get-Item -LiteralPath $partialModel).Length } catch {} + } + + $downloadState = ([string]$state.phase -eq 'download-model') -and ([string]$state.status -in @('running','failed')) + if ($comfyReady -and -not $modelReady -and ($downloadState -or $partialBytes -gt 0)) { + return @{ + Mode = 'model-download' + State = $state + PartialPath = $partialModel + PartialBytes = $partialBytes + } + } + + return $null +} + +function Invoke-LocalFluxStartupRecovery { + $recovery = Get-LocalFluxStartupRecovery + if (-not $recovery) { return } + + Write-Header + Write-Host ' Automatische FLUX-Wiederaufnahme' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + Write-Host '' + + if ($recovery.Mode -eq 'extract') { + Write-OK 'Das ComfyUI-Archiv wurde bereits vollständig und fehlerfrei entpackt.' + Write-Warn 'Der frühere Installer hat den erfolgreichen 7-Zip-Lauf falsch bewertet.' + Write-Host ' Der vorhandene Entpackstand wird übernommen; nichts wird neu entpackt.' -ForegroundColor Green + } elseif ($recovery.Mode -eq 'model-download') { + $partialMb = [math]::Round(([long]$recovery.PartialBytes) / 1MB, 1) + Write-Warn 'Ein unterbrochener oder nicht sichtbar fortschreitender FLUX-Download wurde erkannt.' + if ($partialMb -gt 0) { + Write-OK "Bereits geladene Daten bleiben erhalten: $partialMb MB." + } + Write-Host ' Der Download wird mit sichtbarem Fortschritt automatisch fortgesetzt.' -ForegroundColor Green + Write-Host ' Anzeige: Prozent · geladene MB · Gesamtgröße · Geschwindigkeit · Restzeit' -ForegroundColor DarkGray + } + + Write-Host '' + Write-Step 'AUTO' 'Fortsetzung startet automatisch...' + Start-Sleep -Seconds 2 + + $script = Get-LocalFluxScriptPath + if (-not (Test-Path -LiteralPath $script)) { + Write-Err 'FLUX-Installationsskript fehlt. Automatische Fortsetzung ist nicht möglich.' + return + } + + $logDir = Join-Path $VendooDir 'logs' + if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + } + $selfHealLog = Join-Path $logDir 'setup-self-heal.log' + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Automatische FLUX-Wiederaufnahme gestartet. Modus: $($recovery.Mode)." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + + & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File $script -InstallModel -StartNow + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [OK] Automatische FLUX-Wiederaufnahme erfolgreich abgeschlossen." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + Write-OK 'ComfyUI und FLUX wurden automatisch fertig eingerichtet.' + Write-Host '' + Show-LocalFluxStatus + } else { + "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] Automatische FLUX-Wiederaufnahme fehlgeschlagen. Exit-Code $exitCode." | + Add-Content -LiteralPath $selfHealLog -Encoding utf8 + Write-Err "Automatische Fortsetzung ist fehlgeschlagen (Exit-Code $exitCode)." + Write-Host " Diagnose: $selfHealLog" -ForegroundColor Yellow + Write-Host " Installer-Log: $(Join-Path $VendooDir 'logs\local-flux-install.log')" -ForegroundColor Yellow + } + + Write-Host '' +} + +function Start-LocalFlux { + $script = Join-Path $VendooDir 'local-ai\start-local-flux.ps1' + if (-not (Test-Path $script)) { + Write-Warn 'Startskript fehlt. Bitte lokales FLUX zuerst installieren.' + return + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script + Start-Sleep -Seconds 3 + Show-LocalFluxStatus +} + +function Stop-LocalFlux { + $script = Join-Path $VendooDir 'local-ai\stop-local-flux.ps1' + if (-not (Test-Path $script)) { + Write-Warn 'Stoppskript fehlt.' + return + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script + Write-OK 'Lokaler FLUX / ComfyUI-Prozess wurde beendet.' +} + +function Show-LocalFluxStatus { + Write-Header + Write-Host ' Lokales FLUX / ComfyUI Status' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + + $localRoot = Join-Path $VendooDir 'local-ai' + $stateFile = Join-Path $localRoot 'install-state.json' + $logFile = Join-Path $VendooDir 'logs\local-flux-install.log' + $portableRoot = Join-Path $localRoot 'comfyui\ComfyUI_windows_portable' + $pythonPath = Join-Path $portableRoot 'python_embeded\python.exe' + $comfyMain = Join-Path $portableRoot 'ComfyUI\main.py' + $modelPath = Join-Path $portableRoot 'ComfyUI\models\checkpoints\flux1-schnell-fp8.safetensors' + $workflowPath = Join-Path $localRoot 'workflows\vendoo_flux_schnell_api.json' + $startScript = Join-Path $localRoot 'start-local-flux.ps1' + $stopScript = Join-Path $localRoot 'stop-local-flux.ps1' + + $state = $null + if (Test-Path -LiteralPath $stateFile) { + try { $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json } catch {} + } + + if ($state) { + $statusColor = 'Yellow' + if ($state.status -eq 'completed') { $statusColor = 'Green' } + elseif ($state.status -eq 'failed') { $statusColor = 'Red' } + $heartbeatAge = $null + try { + $heartbeatTime = [DateTime]::Parse([string]$(if ($state.heartbeat_at) { $state.heartbeat_at } else { $state.updated_at })) + $heartbeatAge = [math]::Round(((Get-Date) - $heartbeatTime).TotalSeconds) + } catch {} + $staleThreshold = if ($state.phase -eq 'extract-comfyui') { 20 } else { 120 } + if ($state.status -eq 'running' -and $heartbeatAge -ne $null -and $heartbeatAge -gt $staleThreshold) { + $statusColor = 'Red' + Write-Host ' Status: HÄNGT / keine Aktualisierung' -ForegroundColor $statusColor + } else { + Write-Host " Status: $($state.status)" -ForegroundColor $statusColor + } + Write-Host " Phase: $($state.phase)" -ForegroundColor DarkGray + Write-Host " Fortschritt: $($state.progress)%" -ForegroundColor DarkGray + Write-Host " Meldung: $($state.message)" -ForegroundColor DarkGray + if ($state.elapsed_seconds -ne $null) { Write-Host " Laufzeit: $($state.elapsed_seconds) Sekunden" -ForegroundColor DarkGray } + if ($state.extract_files -ne $null) { Write-Host " Entpackt: $($state.extract_files) Dateien / $($state.extract_mb) MB" -ForegroundColor DarkGray } + if ($heartbeatAge -ne $null) { Write-Host " Heartbeat: vor $heartbeatAge Sekunden" -ForegroundColor DarkGray } + Write-Host " Aktualisiert:$($state.updated_at)" -ForegroundColor DarkGray + } else { + Write-Warn 'Noch keine Installationsstatusdatei vorhanden.' + } + + Write-Host '' + if (Test-Path -LiteralPath "$env:ProgramFiles\7-Zip\7z.exe") { Write-OK '7-Zip Vorhanden' } + else { Write-Warn '7-Zip Nicht im Standardpfad gefunden' } + if (Test-Path -LiteralPath $pythonPath) { Write-OK 'Python Vorhanden' } + else { Write-Warn 'Python Fehlt' } + if (Test-Path -LiteralPath $comfyMain) { Write-OK 'ComfyUI Installiert' } + else { Write-Warn 'ComfyUI Noch nicht vollständig installiert' } + if (Test-Path -LiteralPath $workflowPath) { Write-OK 'Workflow Vorhanden' } + else { Write-Warn 'Workflow Fehlt' } + if (Test-Path -LiteralPath $modelPath) { + $modelSize = [math]::Round((Get-Item -LiteralPath $modelPath).Length / 1GB, 2) + Write-OK "FLUX-Modell Vorhanden ($modelSize GB)" + } else { Write-Warn 'FLUX-Modell Fehlt' } + if (Test-Path -LiteralPath $startScript) { Write-OK 'Startskript Vorhanden' } + else { Write-Warn 'Startskript Fehlt' } + if (Test-Path -LiteralPath $stopScript) { Write-OK 'Stoppskript Vorhanden' } + else { Write-Warn 'Stoppskript Fehlt' } + + $envFile = Join-Path $VendooDir '.env' + $url = 'http://127.0.0.1:8188' + if (Test-Path -LiteralPath $envFile) { + $envContent = Get-Content -LiteralPath $envFile -Raw + if ($envContent -match '(?m)^LOCAL_IMAGE_URL=(.+)$') { $url = $Matches[1].Trim() } + } + $serverReachable = $false + try { + $resp = Invoke-WebRequest -UseBasicParsing -Uri ($url.TrimEnd('/') + '/system_stats') -TimeoutSec 5 + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) { + $serverReachable = $true + Write-OK 'Server Erreichbar' + } else { Write-Warn "Server HTTP $($resp.StatusCode)" } + } catch { + Write-Warn 'Server Nicht erreichbar' + } + + if (-not $serverReachable) { + $runtimeStateFile = Join-Path $VendooDir 'logs\local-flux-runtime-state.json' + if (Test-Path -LiteralPath $runtimeStateFile) { + try { + $runtimeState = Get-Content -LiteralPath $runtimeStateFile -Raw | ConvertFrom-Json + Write-Host " Runtime: $($runtimeState.status)" -ForegroundColor $(if ($runtimeState.status -eq 'failed') { 'Red' } elseif ($runtimeState.status -eq 'starting') { 'Yellow' } else { 'DarkGray' }) + Write-Host " Startinfo: $($runtimeState.message)" -ForegroundColor DarkGray + if ($runtimeState.error) { Write-Host " Fehler: $($runtimeState.error)" -ForegroundColor Red } + } catch {} + } + foreach ($runtimeLog in @( + (Join-Path $VendooDir 'logs\local-flux-runtime-error.log'), + (Join-Path $VendooDir 'logs\local-flux-runtime.log') + )) { + if (Test-Path -LiteralPath $runtimeLog) { + $runtimeLines = @(Get-Content -LiteralPath $runtimeLog -Tail 8 -ErrorAction SilentlyContinue) + if ($runtimeLines.Count -gt 0) { + Write-Host " Runtime-Log: $runtimeLog" -ForegroundColor White + $runtimeLines | ForEach-Object { + $clean = [regex]::Replace([string]$_, ([string][char]27) + '\[[0-?]*[ -/]*[@-~]', '') + Write-Host " $clean" -ForegroundColor DarkGray + } + } + } + } + } + + if (Test-Path -LiteralPath $logFile) { + Write-Host '' + Write-Host ' Letzte Installationsmeldungen:' -ForegroundColor White + Get-Content -LiteralPath $logFile -Tail 12 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } + $sevenZipError = Join-Path $VendooDir 'logs\local-flux-7zip-error.log' + if (Test-Path -LiteralPath $sevenZipError) { + $sevenZipLines = @(Get-Content -LiteralPath $sevenZipError -Tail 8 -ErrorAction SilentlyContinue) + if ($sevenZipLines.Count -gt 0) { + Write-Host '' + Write-Host ' Letzte 7-Zip-Fehlerausgabe:' -ForegroundColor White + $sevenZipLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } + } + Write-Host '' +} + + +function Show-StatusLocal { + Write-Header + Write-Host ' Systemstatus' -ForegroundColor White + Write-Host ' ────────────────────────────────────' -ForegroundColor DarkGray + + $node = Test-NodeInstalled + if ($node.Installed) { Write-OK "Node.js $($node.Version)" } + else { Write-Err 'Node.js Nicht installiert' } + + if (Test-NpmInstalled) { + $npmV = & npm --version 2>$null + Write-OK "npm v$npmV" + } else { Write-Err 'npm Nicht gefunden' } + + $nm = Join-Path $VendooDir 'node_modules' + if (Test-Path $nm) { Write-OK 'Dependencies Installiert' } + else { Write-Warn 'Dependencies Nicht installiert' } + + $envFile = Join-Path $VendooDir '.env' + if (Test-Path $envFile) { Write-OK '.env Vorhanden' } + else { Write-Warn '.env Fehlt' } + + $dbFile = Join-Path $VendooDir 'db' 'vendoo.db' + if (Test-Path $dbFile) { + $size = (Get-Item $dbFile).Length + $sizeStr = if ($size -gt 1MB) { "{0:N1} MB" -f ($size / 1MB) } else { "{0:N0} KB" -f ($size / 1KB) } + Write-OK "Datenbank $sizeStr" + } else { Write-Warn 'Datenbank Noch nicht erstellt (wird beim ersten Start angelegt)' } + + $uploadsDir = Join-Path $VendooDir 'uploads' + if (Test-Path $uploadsDir) { + $fileCount = (Get-ChildItem $uploadsDir -File -ErrorAction SilentlyContinue).Count + Write-OK "Uploads $fileCount Dateien" + } + + Write-Host '' + Write-Host " Port: $Port" -ForegroundColor DarkGray + Write-Host " Verzeichnis: $VendooDir" -ForegroundColor DarkGray + Write-Host '' +} + + +function Show-Status { + if ((Get-VendooInstallMode) -eq 'docker') { + Write-Header + Write-Host ' Docker-Systemstatus' -ForegroundColor White + Write-Host '' + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Write-OK "Docker Engine $($docker.Version)" + Invoke-VendooDockerCompose @('ps') | Out-Null + try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/readyz" -UseBasicParsing -TimeoutSec 5 + if ($response.StatusCode -eq 200) { Write-OK 'Readiness Bereit' } + else { Write-Warn "Readiness HTTP $($response.StatusCode)" } + } catch { Write-Warn 'Readiness Nicht erreichbar' } + Write-Host '' + Write-Host " Installationsmodus: Docker Desktop" -ForegroundColor DarkGray + return + } + Show-StatusLocal +} + +function Uninstall-Vendoo { + if ((Get-VendooInstallMode) -eq 'docker') { + Write-Header + Write-Host ' Vendoo-Dockerbereitstellung entfernen' -ForegroundColor Red + Write-Host '' + Write-Warn 'Container werden gestoppt. Persistente Volumes bleiben standardmäßig erhalten.' + $confirm = Read-Host ' Container wirklich entfernen? (ja/nein)' + if ($confirm -ne 'ja') { Write-Host ' Abgebrochen.' -ForegroundColor Yellow; return } + $docker = Test-DockerAvailable + if (-not $docker.Available) { Write-Err $docker.Reason; return } + Invoke-VendooDockerCompose @('down') | Out-Null + Write-OK 'Container und Netzwerk entfernt. Datenvolumes wurden beibehalten.' + $deleteVolumes = Read-Host ' Persistente Docker-Volumes ebenfalls endgültig löschen? Tippe dafür exakt DATEN LOESCHEN' + if ($deleteVolumes -eq 'DATEN LOESCHEN') { + Invoke-VendooDockerCompose @('down', '-v') | Out-Null + Write-Warn 'Persistente Docker-Volumes wurden gelöscht.' + } + return + } + Uninstall-VendooLocal +} + + +# ── Vendoo Operations Center 1.30.0 ── +function Get-VendooOperationsDir { return (Join-Path $VendooDir 'operations') } +function Get-VendooPendingOperationFile { return (Join-Path (Get-VendooOperationsDir) 'pending-operation.json') } +function Get-VendooLastOperationFile { return (Join-Path (Get-VendooOperationsDir) 'last-operation.json') } + +function Test-VendooServerStopped { + try { + $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if ($listeners.Count -gt 0) { + Write-Err "Vendoo lauscht noch auf Port $Port. Bitte das Serverfenster zuerst schliessen." + return $false + } + } catch { + try { + $client = New-Object System.Net.Sockets.TcpClient + $async = $client.BeginConnect('127.0.0.1', $Port, $null, $null) + if ($async.AsyncWaitHandle.WaitOne(350, $false) -and $client.Connected) { + $client.Close() + Write-Err "Vendoo ist noch aktiv. Bitte das Serverfenster zuerst schliessen." + return $false + } + $client.Close() + } catch {} + } + return $true +} + +function Copy-VendooCodeSnapshot([string]$Target) { + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $rootFiles = @('.env.example','.gitignore','.npmrc','CHANGELOG.md','CLAUDE.md','FEATURES.md','README.md','SECURITY.md','THIRD_PARTY_NOTICES.md','package.json','package-lock.json','server.mjs','setup.bat','setup.ps1','update-manifest.json') + foreach ($name in $rootFiles) { + $source = Join-Path $VendooDir $name + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $Target $name) -Force } + } + foreach ($name in @('public','lib','platforms','scripts','tools','docs','extensions','extension','MockupDesign')) { + $source = Join-Path $VendooDir $name + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $Target $name) -Recurse -Force } + } + $localAiTarget = Join-Path $Target 'local-ai' + New-Item -ItemType Directory -Path $localAiTarget -Force | Out-Null + foreach ($name in @('start-local-flux.ps1','stop-local-flux.ps1')) { + $source = Join-Path $VendooDir ('local-ai\' + $name) + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $localAiTarget $name) -Force } + } + $workflowSource = Join-Path $VendooDir 'local-ai\workflows' + if (Test-Path -LiteralPath $workflowSource) { Copy-Item -LiteralPath $workflowSource -Destination (Join-Path $localAiTarget 'workflows') -Recurse -Force } + $schemaSource = Join-Path $VendooDir 'db\schema.sql' + if (Test-Path -LiteralPath $schemaSource) { + New-Item -ItemType Directory -Path (Join-Path $Target 'db') -Force | Out-Null + Copy-Item -LiteralPath $schemaSource -Destination (Join-Path $Target 'db\schema.sql') -Force + } +} + +function Copy-VendooRuntimeSnapshot([string]$Target) { + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $dbFile = Join-Path $VendooDir 'db\vendoo.db' + if (Test-Path -LiteralPath $dbFile) { + New-Item -ItemType Directory -Path (Join-Path $Target 'db') -Force | Out-Null + Copy-Item -LiteralPath $dbFile -Destination (Join-Path $Target 'db\vendoo.db') -Force + } + $uploads = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploads) { Copy-Item -LiteralPath $uploads -Destination (Join-Path $Target 'uploads') -Recurse -Force } + $envFile = Join-Path $VendooDir '.env' + if (Test-Path -LiteralPath $envFile) { Copy-Item -LiteralPath $envFile -Destination (Join-Path $Target '.env') -Force } +} + +function Copy-VendooTree([string]$Source, [string]$Target) { + if (-not (Test-Path -LiteralPath $Source)) { throw "Quellverzeichnis fehlt: $Source" } + New-Item -ItemType Directory -Path $Target -Force | Out-Null + $null = & robocopy.exe $Source $Target /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP + $code = $LASTEXITCODE + if ($code -gt 7) { throw "Robocopy fehlgeschlagen (Exit-Code $code): $Source -> $Target" } +} + +function Restore-VendooCodeSnapshot([string]$Source) { + if (-not (Test-Path -LiteralPath $Source)) { throw "Rollback-Snapshot fehlt: $Source" } + Copy-VendooTree -Source $Source -Target $VendooDir +} + +function Restore-VendooRuntimeSnapshot([string]$Source) { + if (-not (Test-Path -LiteralPath $Source)) { return } + $dbSource = Join-Path $Source 'db\vendoo.db' + if (Test-Path -LiteralPath $dbSource) { + New-Item -ItemType Directory -Path (Join-Path $VendooDir 'db') -Force | Out-Null + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-wal') -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-shm') -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $dbSource -Destination (Join-Path $VendooDir 'db\vendoo.db') -Force + } + $uploadsSource = Join-Path $Source 'uploads' + if (Test-Path -LiteralPath $uploadsSource) { + $uploadsTarget = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploadsTarget) { Remove-Item -LiteralPath $uploadsTarget -Recurse -Force } + Copy-Item -LiteralPath $uploadsSource -Destination $uploadsTarget -Recurse -Force + } + $envSource = Join-Path $Source '.env' + if (Test-Path -LiteralPath $envSource) { Copy-Item -LiteralPath $envSource -Destination (Join-Path $VendooDir '.env') -Force } +} + +function Find-VendooUpdateRoot([string]$ExtractRoot) { + $packages = @(Get-ChildItem -LiteralPath $ExtractRoot -Filter 'package.json' -File -Recurse -ErrorAction SilentlyContinue) + foreach ($package in $packages) { + try { + $json = Get-Content -LiteralPath $package.FullName -Raw | ConvertFrom-Json + if ($json.name -eq 'vendoo' -and (Test-Path -LiteralPath (Join-Path $package.Directory.FullName 'server.mjs'))) { return $package.Directory.FullName } + } catch {} + } + return $null +} + +function Apply-VendooPendingOperation { + Write-Header + Write-Host ' Vorbereitete Systemoperation anwenden' -ForegroundColor White + Write-Host '' + $pendingFile = Get-VendooPendingOperationFile + if (-not (Test-Path -LiteralPath $pendingFile)) { Write-Warn 'Keine vorbereitete Operation vorhanden.'; return } + if (-not (Test-VendooServerStopped)) { return } + + try { $operation = Get-Content -LiteralPath $pendingFile -Raw | ConvertFrom-Json } + catch { Write-Err 'pending-operation.json ist ungueltig.'; return } + if (-not $operation.verified) { Write-Err 'Die Operation wurde nicht als verifiziert markiert.'; return } + if (-not (Test-Path -LiteralPath $operation.archive_path)) { Write-Err "Archiv fehlt: $($operation.archive_path)"; return } + + Write-Host " Typ: $($operation.type)" -ForegroundColor DarkGray + if ($operation.version_to) { Write-Host " Version: $($operation.version_from) -> $($operation.version_to)" -ForegroundColor DarkGray } + Write-Host " Archiv: $($operation.archive_path)" -ForegroundColor DarkGray + Write-Host '' + $confirm = Read-Host ' Jetzt anwenden? (ja/nein)' + if ($confirm -ne 'ja') { Write-Warn 'Operation abgebrochen.'; return } + + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $rollbackRoot = Join-Path (Get-VendooOperationsDir) ("rollback\" + $stamp) + $codeRollback = Join-Path $rollbackRoot 'code' + $runtimeRollback = Join-Path $rollbackRoot 'runtime' + $extractRoot = Join-Path (Get-VendooOperationsDir) ("apply-" + $stamp) + New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null + + try { + Write-Step '1/5' 'Rollback-Snapshot erstellen...' + Copy-VendooCodeSnapshot -Target $codeRollback + Copy-VendooRuntimeSnapshot -Target $runtimeRollback + Write-OK 'Rollback-Snapshot erstellt' + + Write-Step '2/5' 'Archiv entpacken...' + Expand-Archive -LiteralPath $operation.archive_path -DestinationPath $extractRoot -Force + Write-OK 'Archiv entpackt' + + if ($operation.type -eq 'update') { + Write-Step '3/5' 'Vendoo-Programmdateien aktualisieren...' + $sourceRoot = Find-VendooUpdateRoot -ExtractRoot $extractRoot + if (-not $sourceRoot) { throw 'Vendoo-Quellordner wurde im Update nicht gefunden.' } + Copy-VendooTree -Source $sourceRoot -Target $VendooDir + Write-OK 'Programmdateien aktualisiert' + Write-Step '4/5' 'Dependencies und Konfiguration aktualisieren...' + Update-Vendoo + & node --check (Join-Path $VendooDir 'server.mjs') + if ($LASTEXITCODE -ne 0) { throw 'server.mjs ist nach dem Update syntaktisch ungueltig.' } + if (-not (Test-Path -LiteralPath (Join-Path $VendooDir 'node_modules'))) { throw 'node_modules fehlt nach dem Update.' } + } + elseif ($operation.type -eq 'restore') { + Write-Step '3/5' 'Datenbank wiederherstellen...' + $dbSource = Join-Path $extractRoot 'db\vendoo.db' + if (-not (Test-Path -LiteralPath $dbSource)) { throw 'Datenbank fehlt im Backup.' } + New-Item -ItemType Directory -Path (Join-Path $VendooDir 'db') -Force | Out-Null + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-wal') -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $VendooDir 'db\vendoo.db-shm') -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $dbSource -Destination (Join-Path $VendooDir 'db\vendoo.db') -Force + Write-OK 'Datenbank wiederhergestellt' + Write-Step '4/5' 'Uploads und Konfiguration wiederherstellen...' + $backupManifest = $null + $manifestPath = Join-Path $extractRoot 'backup-manifest.json' + if (Test-Path -LiteralPath $manifestPath) { + try { $backupManifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch {} + } + $uploadsSource = Join-Path $extractRoot 'uploads' + if ($backupManifest -and $backupManifest.include_uploads) { + $uploadsTarget = Join-Path $VendooDir 'uploads' + if (Test-Path -LiteralPath $uploadsTarget) { Remove-Item -LiteralPath $uploadsTarget -Recurse -Force } + if (Test-Path -LiteralPath $uploadsSource) { Copy-Item -LiteralPath $uploadsSource -Destination $uploadsTarget -Recurse -Force } + else { New-Item -ItemType Directory -Path $uploadsTarget -Force | Out-Null } + } + $envSource = Join-Path $extractRoot 'config\.env' + if (Test-Path -LiteralPath $envSource) { Copy-Item -LiteralPath $envSource -Destination (Join-Path $VendooDir '.env') -Force } + Write-OK 'Enthaltene Laufzeitdaten wiederhergestellt' + } + else { throw "Unbekannter Operationstyp: $($operation.type)" } + + Write-Step '5/5' 'Operation abschliessen...' + $last = [ordered]@{ + id = [string]$operation.id + type = [string]$operation.type + status = 'applied' + version_from = [string]$operation.version_from + version_to = [string]$operation.version_to + rollback_path = $rollbackRoot + applied_at = (Get-Date).ToString('o') + } + $last | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Get-VendooLastOperationFile) -Encoding UTF8 + Remove-Item -LiteralPath $pendingFile -Force + Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue + Write-OK 'Operation erfolgreich abgeschlossen' + Write-Host '' + Write-Host ' Starte Vendoo jetzt ueber Menuepunkt 2 und pruefe Admin > System.' -ForegroundColor Green + } catch { + Write-Err ("Operation fehlgeschlagen: " + $_.Exception.Message) + Write-Warn 'Der vorherige Stand wird aus dem gerade erstellten Snapshot wiederhergestellt.' + try { + Restore-VendooCodeSnapshot -Source $codeRollback + Restore-VendooRuntimeSnapshot -Source $runtimeRollback + Write-OK 'Vorheriger Stand wiederhergestellt' + } catch { Write-Err ("Automatischer Rollback fehlgeschlagen: " + $_.Exception.Message) } + } finally { + Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-VendooRollback { + Write-Header + Write-Host ' Letzte Systemoperation zuruecksetzen' -ForegroundColor White + Write-Host '' + if (-not (Test-VendooServerStopped)) { return } + $lastFile = Get-VendooLastOperationFile + if (-not (Test-Path -LiteralPath $lastFile)) { Write-Warn 'Kein Rollback-Snapshot vorhanden.'; return } + try { $last = Get-Content -LiteralPath $lastFile -Raw | ConvertFrom-Json } + catch { Write-Err 'last-operation.json ist ungueltig.'; return } + if (-not $last.rollback_path -or -not (Test-Path -LiteralPath $last.rollback_path)) { Write-Err 'Rollback-Verzeichnis wurde nicht gefunden.'; return } + Write-Host " Letzte Operation: $($last.type)" -ForegroundColor DarkGray + Write-Host " Zeitpunkt: $($last.applied_at)" -ForegroundColor DarkGray + Write-Host '' + if ((Read-Host ' Vorherigen Stand wiederherstellen? (ja/nein)') -ne 'ja') { Write-Warn 'Rollback abgebrochen.'; return } + try { + Restore-VendooCodeSnapshot -Source (Join-Path $last.rollback_path 'code') + Restore-VendooRuntimeSnapshot -Source (Join-Path $last.rollback_path 'runtime') + $last.status = 'rolled_back' + $last | Add-Member -NotePropertyName rolled_back_at -NotePropertyValue (Get-Date).ToString('o') -Force + $last | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $lastFile -Encoding UTF8 + Write-OK 'Vorheriger Vendoo-Stand wurde wiederhergestellt.' + Write-Warn 'Fuehre bei Bedarf danach Menuepunkt 3 – Update aus.' + } catch { Write-Err ("Rollback fehlgeschlagen: " + $_.Exception.Message) } +} + +# ── Main Menu ── +function Show-Menu { + while ($true) { + Write-Header + Write-Host ' Waehle eine Option:' -ForegroundColor White + Write-Host '' + Write-Host ' 1 Installieren / Installationsziel wählen' -ForegroundColor Cyan + Write-Host ' 2 Starten' -ForegroundColor Cyan + Write-Host ' 3 Update' -ForegroundColor Cyan + Write-Host ' 4 Reparieren' -ForegroundColor Cyan + Write-Host ' 5 Deinstallieren' -ForegroundColor Cyan + Write-Host ' 6 Status anzeigen' -ForegroundColor Cyan + Write-Host ' 13 Konfiguration ändern (Assistent)' -ForegroundColor Cyan + Write-Host ' 7 Lokales FLUX / ComfyUI installieren oder aktualisieren' -ForegroundColor Cyan + Write-Host ' 8 Lokales FLUX / ComfyUI Status' -ForegroundColor Cyan + Write-Host ' 9 Lokales FLUX / ComfyUI starten' -ForegroundColor Cyan + Write-Host ' 10 Lokales FLUX / ComfyUI stoppen' -ForegroundColor Cyan + Write-Host ' 11 Vorbereitete Operation anwenden' -ForegroundColor Cyan + Write-Host ' 12 Letzte Operation zuruecksetzen' -ForegroundColor Cyan + Write-Host ' 0 Beenden' -ForegroundColor DarkGray + Write-Host '' + $choice = Read-Host ' Auswahl' + + switch ($choice) { + '1' { Install-Vendoo } + '2' { Start-Vendoo } + '3' { Update-Vendoo } + '4' { Repair-Vendoo } + '5' { Uninstall-Vendoo } + '6' { Show-Status } + '7' { Install-LocalFlux } + '8' { Show-LocalFluxStatus } + '9' { Start-LocalFlux } + '10' { Stop-LocalFlux } + '11' { Apply-VendooPendingOperation } + '12' { Invoke-VendooRollback } + '13' { Configure-VendooConsole } + '0' { Write-Host ' Auf Wiedersehen!' -ForegroundColor Green; return } + default { Write-Warn 'Ungueltige Auswahl' } + } + Write-Host '' + Read-Host ' Druecke Enter um fortzufahren' + } +} + +Remove-LegacyTryOnComponents + +if (-not (Test-VendooPowerShellFiles)) { + Write-Host '' + Write-Err 'Vendoo Setup wurde wegen fehlerhafter oder fehlender Pflichtskripte beendet.' + exit 12 +} + +# Automatische Wiederaufnahme des exakt bestätigten Slice-22-Fehlzustands. +# Direkte CLI-Kommandos bleiben davon unberührt. +if ($args.Count -eq 0) { + Invoke-LocalFluxStartupRecovery +} + +# Handle CLI arguments for direct commands +if ($args.Count -gt 0) { + switch ($args[0]) { + 'install' { Install-Vendoo } + 'install-local' { $script:AutoStartAfterInstall = $true; Set-VendooInstallMode 'windows-local'; Install-VendooLocal } + 'install-docker' { Install-VendooDocker } + 'configure' { Configure-VendooConsole } + 'menu' { Show-Menu } + 'start' { Start-Vendoo } + 'update' { Update-Vendoo } + 'repair' { Repair-Vendoo } + 'uninstall' { Uninstall-Vendoo } + 'status' { Show-Status } + 'install-local-flux' { Install-LocalFlux } + 'flux-status' { Show-LocalFluxStatus } + 'flux-start' { Start-LocalFlux } + 'flux-stop' { Stop-LocalFlux } + 'apply-operation' { Apply-VendooPendingOperation } + 'rollback' { Invoke-VendooRollback } + default { Show-Menu } + } +} else { + Show-Menu +} diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..93e9b19 --- /dev/null +++ b/setup.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env sh +set -eu + +APP_VERSION="1.36.0" +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +MODE_FILE="$ROOT_DIR/.vendoo-install-mode" +ENV_FILE="$ROOT_DIR/.env" +ENV_EXAMPLE="$ROOT_DIR/.env.example" + +info() { printf '\033[36m[INFO]\033[0m %s\n' "$*"; } +ok() { printf '\033[32m[OK]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[WARN]\033[0m %s\n' "$*"; } +err() { printf '\033[31m[ERROR]\033[0m %s\n' "$*" >&2; } + +has_command() { command -v "$1" >/dev/null 2>&1; } + +get_mode() { + if [ -f "$MODE_FILE" ]; then tr -d '\r\n' < "$MODE_FILE"; else printf 'docker'; fi +} + +set_mode() { + printf '%s\n' "$1" > "$MODE_FILE" + chmod 600 "$MODE_FILE" 2>/dev/null || true +} + +compose_files() { + case "$(get_mode)" in + nas) printf '%s:%s' "$ROOT_DIR/compose.yaml" "$ROOT_DIR/compose.bind-mounts.example.yaml" ;; + vps) printf '%s' "$ROOT_DIR/compose.vps.yaml" ;; + *) printf '%s' "$ROOT_DIR/compose.yaml" ;; + esac +} + +compose() { + COMPOSE_FILE=$(compose_files) docker compose "$@" +} + +validate_env_value() { + case "$2" in + *'\n'*|*'\r'*) err "Ungültiger mehrzeiliger Wert für $1."; return 1 ;; + esac +} + +set_env() { + key=$1 + value=$2 + validate_env_value "$key" "$value" + tmp="$ENV_FILE.tmp.$$" + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + awk -v k="$key" -v v="$value" 'BEGIN{FS="="} $1==k{print k"="v; next} {print}' "$ENV_FILE" > "$tmp" + else + cat "$ENV_FILE" > "$tmp" + printf '\n%s=%s\n' "$key" "$value" >> "$tmp" + fi + chmod 600 "$tmp" 2>/dev/null || true + mv "$tmp" "$ENV_FILE" +} + +get_env() { + key=$1 + sed -n "s/^${key}=//p" "$ENV_FILE" 2>/dev/null | tail -n 1 +} + +generate_token() { + if has_command openssl; then openssl rand -hex 32 + elif [ -r /dev/urandom ]; then od -An -N32 -tx1 /dev/urandom | tr -d ' \n' + else date +%s | sha256sum | awk '{print $1}' + fi +} + +check_docker() { + if ! has_command docker; then + err "Docker ist auf diesem Zielsystem nicht installiert." + printf '%s\n' "Installiere Docker Engine und das Compose-Plugin nach der offiziellen Docker-Dokumentation und starte setup.sh danach erneut." + return 1 + fi + if ! docker compose version >/dev/null 2>&1; then + err "Das Docker-Compose-Plugin fehlt." + return 1 + fi + if ! docker info >/dev/null 2>&1; then + err "Docker ist installiert, aber der Dienst ist nicht erreichbar oder dein Benutzer hat keine Berechtigung." + return 1 + fi + ok "Docker und Docker Compose sind verfügbar." +} + +ensure_env() { + if [ ! -f "$ENV_FILE" ]; then + cp "$ENV_EXAMPLE" "$ENV_FILE" + chmod 600 "$ENV_FILE" 2>/dev/null || true + ok ".env wurde automatisch aus der sicheren Vorlage erstellt." + fi + token=$(get_env VENDOO_SETUP_TOKEN) + if [ -z "$token" ]; then + token=$(generate_token) + set_env VENDOO_SETUP_TOKEN "$token" + ok "Ein zufälliger Erst-Admin-Setup-Code wurde erzeugt." + fi + set_env VENDOO_REQUIRE_SETUP_TOKEN true +} + +read_default() { + prompt=$1 + default=$2 + printf '%s [%s]: ' "$prompt" "$default" >&2 + read -r value + if [ -z "$value" ]; then printf '%s' "$default"; else printf '%s' "$value"; fi +} + +read_yes_no() { + prompt=$1 + default=$2 + while :; do + printf '%s [%s]: ' "$prompt" "$default" + read -r answer + [ -n "$answer" ] || answer=$default + case "$answer" in + j|J|ja|JA|y|Y|yes|YES) return 0 ;; + n|N|nein|NEIN|no|NO) return 1 ;; + *) warn "Bitte ja oder nein eingeben." ;; + esac + done +} + +validate_port() { + case "$1" in ''|*[!0-9]*) return 1 ;; esac + [ "$1" -ge 1024 ] && [ "$1" -le 65535 ] +} + +validate_domain() { + case "$1" in + http://*|https://*|*/*|*:*|'') return 1 ;; + *.*) return 0 ;; + *) return 1 ;; + esac +} + +configure_wizard() { + ensure_env + printf '\nVendoo %s – geführte Installation\n\n' "$APP_VERSION" + printf ' 1 Docker auf Linux/macOS/Docker Desktop (nur lokal erreichbar)\n' + printf ' 2 NAS / Portainer / privates LAN (sichtbare Runtime-Ordner)\n' + printf ' 3 VPS / öffentlicher Server mit automatischem HTTPS\n' + printf ' 0 Abbrechen\n\n' + printf 'Installationsziel: ' + read -r choice + case "$choice" in + 1) mode=docker ;; + 2) mode=nas ;; + 3) mode=vps ;; + 0) return 1 ;; + *) err "Ungültige Auswahl."; return 1 ;; + esac + set_mode "$mode" + + current_port=$(get_env VENDOO_HOST_PORT) + [ -n "$current_port" ] || current_port=8124 + port=$(read_default "Port" "$current_port") + validate_port "$port" || { err "Port muss zwischen 1024 und 65535 liegen."; return 1; } + set_env PORT "$port" + set_env VENDOO_HOST_PORT "$port" + set_env VENDOO_COOKIE_SAMESITE lax + set_env VENDOO_ALLOW_ANY_EXTENSION_ORIGIN false + + case "$mode" in + docker) + set_env VENDOO_PUBLISH_ADDRESS 127.0.0.1 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY false + set_env VENDOO_COOKIE_SECURE auto + set_env PUBLIC_BASE_URL "" + ;; + nas) + mkdir -p "$ROOT_DIR/runtime/db" "$ROOT_DIR/runtime/uploads" "$ROOT_DIR/runtime/backups" "$ROOT_DIR/runtime/operations" "$ROOT_DIR/runtime/logs" "$ROOT_DIR/runtime/config" + chmod -R u+rwX "$ROOT_DIR/runtime" 2>/dev/null || true + set_env VENDOO_PUBLISH_ADDRESS 0.0.0.0 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY false + set_env VENDOO_COOKIE_SECURE auto + printf 'LAN-Basisadresse für QR-Uploads, z. B. http://192.168.1.20:%s (optional): ' "$port" + read -r public_url + case "$public_url" in + '') set_env PUBLIC_BASE_URL "" ;; + http://*|https://*) set_env PUBLIC_BASE_URL "${public_url%/}" ;; + *) err "Adresse muss mit http:// oder https:// beginnen."; return 1 ;; + esac + warn "NAS-Modus veröffentlicht Port $port im privaten LAN. Nicht direkt aus dem Internet freigeben." + ;; + vps) + while :; do + printf 'Öffentliche Domain ohne https://, z. B. vendoo.example.de: ' + read -r domain + validate_domain "$domain" && break + warn "Bitte einen vollständigen Domainnamen ohne Protokoll oder Pfad eingeben." + done + set_env VENDOO_DOMAIN "$domain" + set_env PUBLIC_BASE_URL "https://$domain" + set_env VENDOO_PUBLISH_ADDRESS 127.0.0.1 + set_env VENDOO_BIND_HOST 0.0.0.0 + set_env VENDOO_TRUST_PROXY true + set_env VENDOO_COOKIE_SECURE true + set_env VENDOO_ALLOWED_HOSTS "$domain" + info "Caddy übernimmt HTTPS automatisch. DNS muss auf diesen Server zeigen; Ports 80 und 443 müssen erreichbar sein." + ;; + esac + + if read_yes_no "Lokales/externes FLUX aktivieren?" "nein"; then + set_env LOCAL_IMAGE_ENABLED true + default_flux=http://host.docker.internal:8188 + flux_url=$(read_default "FLUX/ComfyUI-Adresse" "$default_flux") + case "$flux_url" in http://*|https://*) set_env LOCAL_IMAGE_URL "${flux_url%/}" ;; *) err "FLUX-Adresse muss mit http:// oder https:// beginnen."; return 1 ;; esac + else + set_env LOCAL_IMAGE_ENABLED false + fi + + token=$(get_env VENDOO_SETUP_TOKEN) + printf '\nERST-ADMIN-SETUP-CODE:\n%s\n\n' "$token" + warn "Code bis zur ersten Anmeldung sicher aufbewahren. AI- und Marktplatz-Schlüssel werden später in Vendoo → Einstellungen gepflegt." + ok "Konfiguration gespeichert; keine Datei musste manuell bearbeitet werden." +} + +wait_ready() { + case "$(get_mode)" in + vps) url="https://$(get_env VENDOO_DOMAIN)/readyz" ;; + *) url="http://127.0.0.1:$(get_env VENDOO_HOST_PORT)/readyz" ;; + esac + if ! has_command curl; then warn "curl fehlt; Readiness später im Browser prüfen: $url"; return 0; fi + i=0 + while [ "$i" -lt 30 ]; do + if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then ok "Vendoo ist bereit: $url"; return 0; fi + i=$((i+1)); sleep 2 + done + warn "Readiness war noch nicht erfolgreich. Prüfe: compose logs --tail=200" + return 0 +} + +install_or_update() { + check_docker + configure_wizard || return 0 + info "Prüfe Compose-Konfiguration …" + compose config >/dev/null + ok "Compose-Konfiguration ist gültig." + info "Baue und starte Vendoo …" + compose build --pull + compose up -d --remove-orphans + compose ps + wait_ready +} + +start_app() { check_docker; compose up -d; compose ps; wait_ready; } +stop_app() { check_docker; compose down; } +update_app() { + check_docker + ensure_env + info "Erstelle vor dem Update automatisch ein Backup …" + backup_app + compose config >/dev/null + compose build --pull + compose up -d --remove-orphans + compose ps + wait_ready +} +status_app() { + check_docker + compose ps + printf '\nReadiness:\n' + case "$(get_mode)" in vps) url="https://$(get_env VENDOO_DOMAIN)/readyz" ;; *) url="http://127.0.0.1:$(get_env VENDOO_HOST_PORT)/readyz" ;; esac + if has_command curl; then curl -fsS "$url" || true; printf '\n'; else warn "curl fehlt; Readiness bitte im Browser prüfen: $url"; fi +} +backup_app() { + check_docker + COMPOSE_FILE=$(compose_files) "$ROOT_DIR/scripts/docker-backup.sh" +} +restore_app() { + printf 'Pfad zum Backup-TAR.GZ: ' + read -r backup + [ -f "$backup" ] || { err "Backup nicht gefunden."; return 1; } + check_docker + COMPOSE_FILE=$(compose_files) "$ROOT_DIR/scripts/docker-restore.sh" "$backup" --confirm + wait_ready +} +show_setup_code() { + ensure_env + printf '\nErst-Admin-Setup-Code:\n%s\n\n' "$(get_env VENDOO_SETUP_TOKEN)" +} + +menu() { + while :; do + printf '\nVendoo %s – Zero-Edit Deployment Assistant\n\n' "$APP_VERSION" + printf ' 1 Installieren / Konfiguration ändern\n' + printf ' 2 Starten\n' + printf ' 3 Stoppen\n' + printf ' 4 Status und Readiness\n' + printf ' 5 Aktualisieren (mit Sicherheitsbackup)\n' + printf ' 6 Backup erstellen\n' + printf ' 7 Backup wiederherstellen\n' + printf ' 8 Erst-Admin-Code anzeigen\n' + printf ' 0 Beenden\n\n' + printf 'Auswahl: ' + read -r choice + case "$choice" in + 1) install_or_update ;; + 2) start_app ;; + 3) stop_app ;; + 4) status_app ;; + 5) update_app ;; + 6) backup_app ;; + 7) restore_app ;; + 8) show_setup_code ;; + 0) exit 0 ;; + *) warn "Ungültige Auswahl." ;; + esac + done +} + +case "${1:-menu}" in + install|configure) install_or_update ;; + start) start_app ;; + stop) stop_app ;; + status) status_app ;; + update) update_app ;; + backup) backup_app ;; + restore) restore_app ;; + setup-code) show_setup_code ;; + menu) menu ;; + *) err "Unbekannter Befehl: $1"; exit 2 ;; +esac diff --git a/tools/build-release.mjs b/tools/build-release.mjs new file mode 100644 index 0000000..6efc321 --- /dev/null +++ b/tools/build-release.mjs @@ -0,0 +1,54 @@ +import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { shouldSkipStagingEntry } from './prepare-git-staging.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const version = pkg.version; +const outputRoot = join(root, 'release-output'); +const packageName = `Vendoo_${version}`; +const stage = join(outputRoot, packageName); +const normalize = value => value.split(sep).join('/'); + +rmSync(outputRoot, { recursive: true, force: true }); +mkdirSync(stage, { recursive: true }); + +function copyTree(src, dest, rel = '') { + for (const name of readdirSync(src)) { + const source = join(src, name); + const childRel = rel ? join(rel, name) : name; + const stat = lstatSync(source); + if (shouldSkipStagingEntry(childRel, stat)) continue; + const target = join(dest, name); + if (stat.isDirectory()) { + mkdirSync(target, { recursive: true }); + copyTree(source, target, childRel); + } else if (stat.isFile()) { + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target); + } + } +} +copyTree(root, stage); + +const checksums = []; +function hashTree(dir) { + for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b))) { + const abs = join(dir, name); + const stat = lstatSync(abs); + if (stat.isDirectory()) hashTree(abs); + else { + const rel = normalize(relative(stage, abs)); + if (rel === 'FILE_CHECKSUMS_SHA256.txt') continue; + const digest = createHash('sha256').update(readFileSync(abs)).digest('hex'); + checksums.push(`${digest} ${rel}`); + } + } +} +hashTree(stage); +writeFileSync(join(stage, 'FILE_CHECKSUMS_SHA256.txt'), checksums.join('\n') + '\n', 'utf8'); +writeFileSync(join(outputRoot, 'RELEASE_NAME.txt'), packageName + '\n', 'utf8'); +console.log(`Release-Staging erstellt: ${stage}`); +console.log(`${checksums.length} Dateien mit SHA-256 erfasst.`); diff --git a/tools/generate-design-tokens.mjs b/tools/generate-design-tokens.mjs new file mode 100644 index 0000000..5ca41c5 --- /dev/null +++ b/tools/generate-design-tokens.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sourcePath = join(root, 'public', 'design-system', 'tokens', 'source.json'); +const cssPath = join(root, 'public', 'design-system', 'tokens.css'); +const contractPath = join(root, 'public', 'design-system', 'theme-contract.json'); +const source = JSON.parse(readFileSync(sourcePath, 'utf8')); +const failures = []; + +if (source.schemaVersion !== 1) failures.push('schemaVersion muss 1 sein'); +if (!/^\d+\.\d+\.\d+$/.test(String(source.contractVersion || ''))) failures.push('contractVersion ist ungültig'); +if (!Array.isArray(source.themes) || !source.themes.includes('light') || !source.themes.includes('dark')) failures.push('light und dark müssen definiert sein'); + +const cssVars = new Set(); +for (const [name, token] of Object.entries(source.tokens || {})) { + if (!/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/.test(name)) failures.push(`Ungültiger Tokenname: ${name}`); + if (!/^--vd-[a-z0-9-]+$/.test(String(token.css || ''))) failures.push(`Ungültige CSS-Variable: ${name}`); + if (cssVars.has(token.css)) failures.push(`Doppelte CSS-Variable: ${token.css}`); + cssVars.add(token.css); + for (const theme of source.themes) if (typeof token[theme] !== 'string' || !token[theme].trim()) failures.push(`${name} fehlt in Theme ${theme}`); +} +if (failures.length) { + console.error(`Design-Token-Generierung fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} + +function block(selector, theme) { + const lines = Object.values(source.tokens).map(token => ` ${token.css}: ${token[theme]};`); + return `${selector} {\n${lines.join('\n')}\n}`; +} + +const css = `/* Automatisch generiert aus design-system/tokens/source.json. Nicht direkt bearbeiten. */\n` + + `/* Contract ${source.contractVersion} */\n` + + `${block(':root', 'light')}\n\n${block('[data-theme="dark"]', 'dark')}\n\n${block('[data-theme="contrast"]', 'contrast')}\n\n` + + `@media (prefers-reduced-motion: reduce) {\n :root { --vd-motion-fast: 0ms; --vd-motion-normal: 0ms; }\n}\n`; +writeFileSync(cssPath, css, 'utf8'); + +const contract = { + schemaVersion: source.schemaVersion, + contractVersion: source.contractVersion, + themes: source.themes, + tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, { + css: token.css, + type: token.type, + customizable: Boolean(token.customizable), + label: token.label || name, + group: token.group || name.split('.')[0], + ...(token.min !== undefined ? { min: token.min } : {}), + ...(token.max !== undefined ? { max: token.max } : {}), + }])), +}; +writeFileSync(contractPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8'); +console.log(`Design Tokens erzeugt: ${Object.keys(source.tokens).length} Tokens, ${source.themes.length} Themes.`); diff --git a/tools/prepare-git-staging.mjs b/tools/prepare-git-staging.mjs new file mode 100644 index 0000000..6277283 --- /dev/null +++ b/tools/prepare-git-staging.mjs @@ -0,0 +1,106 @@ +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, +} from 'node:fs'; +import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const normalize = value => value.split(sep).join('/').replace(/^\.\//, ''); +const allowedEnvTemplates = new Set(['.env.example', '.env.docker.example']); +const skippedTopLevelDirs = new Set([ + 'node_modules', 'runtime', 'docker-data', 'uploads', 'backups', 'logs', + 'sessions', 'tmp', 'temp', 'release-output', 'operations', 'updates', +]); +const skippedLocalAiDirs = new Set(['local-ai/comfyui', 'local-ai/downloads', 'local-ai/extract-temp']); +const skippedExactFiles = new Set([ + '.vendoo-install-mode', '.vendoo-github-state.json', + 'pending-operation.json', 'last-operation.json', 'first-admin-code.txt', +]); +const skippedExtensions = new Set([ + '.db', '.sqlite', '.sqlite3', '.log', '.key', '.pem', '.pfx', '.p12', +]); + +export function shouldSkipStagingEntry(relativePath, stat) { + const rel = normalize(relativePath); + const parts = rel.split('/'); + const name = basename(rel); + const lower = rel.toLowerCase(); + + if (!rel || rel === '.git' || lower.startsWith('.git/')) return true; + if (skippedTopLevelDirs.has(parts[0].toLowerCase())) return true; + if ([...skippedLocalAiDirs].some(dir => lower === dir || lower.startsWith(`${dir}/`))) return true; + if (/^\.env(?:\..+)?$/i.test(name) && !allowedEnvTemplates.has(rel)) return true; + if (skippedExactFiles.has(name.toLowerCase())) return true; + if (/\.(?:db|sqlite|sqlite3)-(?:wal|shm)$/i.test(name)) return true; + if (skippedExtensions.has(extname(name).toLowerCase())) return true; + if (stat.isSymbolicLink()) return true; + return false; +} + +function cleanDestination(destination) { + if (!existsSync(join(destination, '.git'))) { + throw new Error(`Ziel ist kein geklontes Git-Repository: ${destination}`); + } + for (const name of readdirSync(destination)) { + if (name === '.git') continue; + rmSync(join(destination, name), { recursive: true, force: true }); + } +} + +export function prepareGitStaging(sourceRoot, destinationRoot) { + const source = resolve(sourceRoot); + const destination = resolve(destinationRoot); + if (source === destination) throw new Error('Quell- und Zielordner dürfen nicht identisch sein.'); + if (!existsSync(source)) throw new Error(`Quellordner fehlt: ${source}`); + if (!existsSync(destination)) throw new Error(`Zielordner fehlt: ${destination}`); + + cleanDestination(destination); + let copied = 0; + let skipped = 0; + + function walk(current) { + for (const name of readdirSync(current)) { + const absolute = join(current, name); + const rel = normalize(relative(source, absolute)); + const stat = lstatSync(absolute); + if (shouldSkipStagingEntry(rel, stat)) { + skipped += 1; + continue; + } + const target = join(destination, rel); + if (stat.isDirectory()) { + mkdirSync(target, { recursive: true }); + walk(absolute); + } else if (stat.isFile()) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(absolute, target); + try { chmodSync(target, stat.mode & 0o777); } catch {} + copied += 1; + } + } + } + + walk(source); + return { source, destination, copied, skipped }; +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''; +if (invokedPath === fileURLToPath(import.meta.url)) { + const [, , source, destination] = process.argv; + if (!source || !destination) { + console.error('Aufruf: node tools/prepare-git-staging.mjs '); + process.exit(64); + } + try { + const result = prepareGitStaging(source, destination); + console.log(`Git-Staging vorbereitet: ${result.copied} Quelldateien kopiert; ${result.skipped} lokale Einträge ausgeschlossen.`); + } catch (error) { + console.error(`[FEHLER] Git-Staging konnte nicht vorbereitet werden: ${error.message}`); + process.exit(1); + } +} diff --git a/tools/verify-config-store.mjs b/tools/verify-config-store.mjs new file mode 100644 index 0000000..0993e25 --- /dev/null +++ b/tools/verify-config-store.mjs @@ -0,0 +1,23 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-config-store-')); +const path = join(temp, 'config', 'vendoo.env'); +process.env.VENDOO_CONFIG_PATH = path; + +try { + const mod = await import(`../lib/config-store.mjs?test=${Date.now()}`); + mod.updateRuntimeConfig({ OPENAI_API_KEY: 'test-only-value', PUBLIC_BASE_URL: 'https://vendoo.example.invalid' }); + const content = readFileSync(path, 'utf8'); + if (!content.includes('OPENAI_API_KEY=test-only-value')) throw new Error('OPENAI_API_KEY wurde nicht gespeichert.'); + if (!content.includes('PUBLIC_BASE_URL=https://vendoo.example.invalid')) throw new Error('PUBLIC_BASE_URL wurde nicht gespeichert.'); + if (process.env.OPENAI_API_KEY !== 'test-only-value') throw new Error('process.env wurde nicht synchronisiert.'); + let rejected = false; + try { mod.updateRuntimeConfig({ SMTP_PASS: 'line1\nline2' }); } catch { rejected = true; } + if (!rejected) throw new Error('Mehrzeiliger Secret-Wert wurde nicht abgelehnt.'); + if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0) throw new Error('Konfigurationsdatei ist nicht auf 0600 begrenzt.'); + console.log('Konfigurationsspeicher erfolgreich geprüft: persistent, restriktiv und newline-geschützt.'); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-db-migrations.py b/tools/verify-db-migrations.py new file mode 100644 index 0000000..aabf1b6 --- /dev/null +++ b/tools/verify-db-migrations.py @@ -0,0 +1,136 @@ +from pathlib import Path +import re, sqlite3, tempfile, json, sys + +APP = Path(__file__).resolve().parents[1] +BASE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else None + +def exec_db_exec_blocks(conn, source): + text = Path(source).read_text(encoding='utf-8') + blocks = re.findall(r"db\.exec\(`(.*?)`\)", text, flags=re.S) + applied = 0 + for sql in blocks: + if '${' in sql: + continue + try: + conn.executescript(sql) + applied += 1 + except sqlite3.DatabaseError: + # db.mjs intentionally catches additive ALTER errors. + pass + if 'operations-center-1.30.0' in text: + conn.execute("INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('operations-center-1.30.0','1.30.0','Backup, Restore, Update Center und Extension-Diagnose')") + return applied + +def columns(conn, table): + return {row[1] for row in conn.execute(f'PRAGMA table_info({table})')} + +def verify(conn): + required_tables = { + 'flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions', + 'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports', + 'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings', + 'update_operations', 'system_migrations', 'extension_clients', 'users', 'sessions', 'audit_log', 'resource_locks', 'theme_profiles', 'user_theme_preferences' + } + existing_tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + missing_tables = required_tables - existing_tables + checks = { + 'flux_images': {'favorite','job_id','variant_index','prompt_mode','parameters_json'}, + 'flux_prompt_jobs': {'archived_at'}, + 'image_edit_versions': {'root_path','source_path','output_path','context','operations_json','width','height','format','quality','file_size','user_id','created_at'}, + 'image_batch_jobs': {'name','status','phase','source_count','profile_count','total_items','completed_items','failed_items','cancelled_items','pause_requested','cancel_requested','settings_json','created_at','updated_at'}, + 'image_batch_job_items': {'job_id','source_path','profile_id','profile_key','profile_name','status','phase','settings_json','output_path','error_message','created_at','updated_at'}, + 'quality_reports': {'listing_id','score','grade','ready','blockers','warnings','recommendations','report_json','analyzed_at'}, + 'publishing_jobs': {'id','listing_id','platform','mode','action','status','phase','idempotency_key','external_id','external_url','offer_id','retries','max_retries','next_attempt_at','claimed_by','claimed_at','error_code','error_message','payload_json','result_json','created_at','started_at','completed_at','updated_at'}, + 'publishing_events': {'id','job_id','listing_id','platform','level','event_type','message','details_json','created_at'}, + 'publish_log': {'offer_id','sync_status','last_synced_at','ended_at','updated_at'}, + 'operation_backups': {'id','kind','label','file_name','file_path','file_size','sha256','status','verified_at','options_json','manifest_json','created_at'}, + 'operation_settings': {'key','value','updated_at'}, + 'update_operations': {'id','version_from','version_to','source_name','archive_path','sha256','status','details_json','created_at','applied_at','error_message'}, + 'system_migrations': {'id','migration_key','app_version','description','applied_at'}, + 'extension_clients': {'client_id','browser','version','platform','server_url','user_agent','capabilities_json','first_seen','last_seen'}, + 'users': {'id','email','name','password_hash','role','active','avatar_color','last_active_at','failed_login_count','locked_until','password_changed_at','created_at','invited_by'}, + 'sessions': {'id','user_id','token_hash','ip','user_agent','expires_at','last_seen_at','revoked_at','created_at'}, + 'audit_log': {'id','user_id','user_email','action','target_type','target_id','details','ip','created_at'}, + 'resource_locks': {'id','resource_type','resource_id','user_id','token_hash','acquired_at','renewed_at','expires_at','client_label'}, + 'theme_profiles': {'id','name','base_theme','values_json','created_by','created_at','updated_at'}, + 'user_theme_preferences': {'user_id','theme_id','density','reduced_motion','updated_at'}, + } + missing_columns = {table: sorted(required - columns(conn, table)) for table, required in checks.items() if required - columns(conn, table)} + if missing_tables or missing_columns: + raise AssertionError({'missing_tables': sorted(missing_tables), 'missing_columns': missing_columns}) + +with tempfile.TemporaryDirectory() as temp: + fresh = sqlite3.connect(Path(temp) / 'fresh.db') + fresh.executescript((APP / 'db/schema.sql').read_text(encoding='utf-8')) + fresh_blocks = exec_db_exec_blocks(fresh, APP / 'lib/db.mjs') + verify(fresh) + + existing = sqlite3.connect(Path(temp) / 'existing.db') + existing.executescript((APP / 'db/schema.sql').read_text(encoding='utf-8')) + if BASE: + exec_db_exec_blocks(existing, BASE / 'lib/db.mjs') + existing.execute("INSERT INTO flux_images (prompt,image_path,width,height,seed,steps,style) VALUES ('Bestand','ai-generated/existing.png',768,768,42,4,'none')") + existing.commit() + existing_blocks = exec_db_exec_blocks(existing, APP / 'lib/db.mjs') + verify(existing) + row = existing.execute("SELECT prompt,image_path,favorite FROM flux_images WHERE image_path='ai-generated/existing.png'").fetchone() + if row != ('Bestand','ai-generated/existing.png',0): + raise AssertionError(f'Bestandsdaten verändert: {row!r}') + + jobs = [('job-complete', 'completed'), ('job-failed', 'failed'), ('job-active', 'running')] + insert_job = """INSERT INTO flux_prompt_jobs + (id,prompt,final_prompt,width,height,steps,style,profile,prompt_mode,assistant,variants,seed_base,seed_lock,status,phase,settings_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""" + for job_id, status in jobs: + existing.execute(insert_job, (job_id,'Test','Test',768,768,6,'none','balanced','precise',1,1,42,0,status,status,'{}')) + existing.commit() + existing.execute("UPDATE flux_prompt_jobs SET archived_at=datetime('now') WHERE id IN ('job-complete','job-failed','job-active') AND status NOT IN ('queued','running','cancelling')") + archived = {row[0] for row in existing.execute("SELECT id FROM flux_prompt_jobs WHERE archived_at IS NOT NULL")} + if archived != {'job-complete','job-failed'}: + raise AssertionError(f'Archivschutz inkorrekt: {archived!r}') + + existing.execute("INSERT INTO listings (title, platform, ai_provider, photos, tags) VALUES ('Publishing Test', 'ebay-de', 'openai', '[]', '[]')") + listing_id = existing.execute("SELECT last_insert_rowid()").fetchone()[0] + existing.execute("""INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) + VALUES ('pub-job-1', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')""", (listing_id,)) + existing.execute("""INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message) + VALUES ('pub-job-1', ?, 'ebay-de', 'info', 'created', 'Auftrag erstellt')""", (listing_id,)) + duplicate_guard = False + try: + existing.execute("""INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) + VALUES ('pub-job-2', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')""", (listing_id,)) + except sqlite3.IntegrityError: + duplicate_guard = True + if not duplicate_guard: + raise AssertionError('Idempotency-Key verhindert keinen doppelten Auftrag') + existing.execute("UPDATE publishing_jobs SET status='running', phase='publish' WHERE id='pub-job-1'") + existing.execute("UPDATE publishing_jobs SET status='queued', phase='recovered' WHERE status='running'") + recovered = existing.execute("SELECT status, phase FROM publishing_jobs WHERE id='pub-job-1'").fetchone() + if recovered != ('queued', 'recovered'): + raise AssertionError(f'Publishing-Recovery fehlgeschlagen: {recovered!r}') + + existing.execute("INSERT INTO operation_settings (key,value) VALUES ('auto_backup_enabled','1')") + existing.execute("INSERT INTO operation_backups (id,kind,file_name,file_path,status) VALUES ('backup-1','manual','test.zip','/tmp/test.zip','verified')") + existing.execute("INSERT INTO update_operations (id,version_from,version_to,archive_path,status) VALUES ('update-1','1.29.0','1.30.0','/tmp/update.zip','staged')") + existing.execute("INSERT INTO extension_clients (client_id,browser,version,platform) VALUES ('client-1','chrome','1.9.0','popup')") + migration = existing.execute("SELECT app_version FROM system_migrations WHERE migration_key='operations-center-1.30.0'").fetchone() + if migration != ('1.30.0',): + raise AssertionError(f'Operations-Migration fehlt: {migration!r}') + extension = existing.execute("SELECT browser,version FROM extension_clients WHERE client_id='client-1'").fetchone() + if extension != ('chrome','1.9.0'): + raise AssertionError(f'Extension-Client fehlerhaft: {extension!r}') + + print(json.dumps({ + 'ok': True, + 'fresh_db_exec_blocks': fresh_blocks, + 'existing_db_exec_blocks': existing_blocks, + 'existing_row_preserved': True, + 'publishing_hardening': { + 'tables': ['publishing_jobs','publishing_events'], + 'idempotency_key_unique': True, + 'restart_recovery': True, + 'events_persisted': True, + }, + 'operations_center': {'backup_tables': True, 'update_tables': True, 'extension_heartbeat': True, 'migration_recorded': True}, + 'tables': ['flux_images','flux_prompt_jobs','flux_prompt_job_variants','image_edit_versions','image_batch_profiles','image_batch_jobs','image_batch_job_items','quality_reports','publishing_jobs','publishing_events','operation_backups','operation_settings','update_operations','system_migrations','extension_clients','theme_profiles','user_theme_preferences'], + }, indent=2)) diff --git a/tools/verify-deployment-1.32.0.mjs b/tools/verify-deployment-1.32.0.mjs new file mode 100644 index 0000000..a397297 --- /dev/null +++ b/tools/verify-deployment-1.32.0.mjs @@ -0,0 +1,70 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const dockerfile = read('Dockerfile'); +const compose = read('compose.yaml'); +const env = read('.env.example'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const setup = read('setup.ps1'); +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.32.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.32.0"', 'style.css?v=1.32.0', 'app.js?v=1.32.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.32.0';")) failures.push('UI-Build ist nicht 1.32.0'); +for (const marker of ["$Version = '1.32.0'", '?build=1.32.0']) expect(setup, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + "app.get('/healthz'", "app.get('/readyz'", 'gracefulShutdown', "process.once('SIGTERM'", + 'verifyRuntimeWritable', 'VENDOO_SETUP_TOKEN', 'VENDOO_EXTENSION_ORIGINS', 'setup_required: true', + "app.use('/uploads', requireAuth", 'allowed.includes(ext) && isImageMime', +]) expect(server, marker); + +for (const marker of ['VENDOO_DATA_DIR', 'VENDOO_DB_PATH', 'VENDOO_UPLOADS_DIR', 'VENDOO_BACKUP_DIR', 'VENDOO_OPERATIONS_DIR']) expect(runtimePaths, marker); + + +if (server.includes('if (isSetupMode()) return next();')) failures.push('Unsichere globale Auth-Ausnahme im Setup-Modus ist noch vorhanden'); + +for (const forbiddenMarker of [ + "resolve(__dirname, 'uploads')", "resolve(__dirname, 'uploads',", "join(__dirname, 'uploads',", + "join(__dirname, 'logs'", "join(root, 'uploads')", "join(root, 'db')", +]) if (server.includes(forbiddenMarker)) failures.push(`Server enthält noch codegebundenen Laufzeitpfad: ${forbiddenMarker}`); +for (const marker of ['FROM node:22-bookworm-slim', 'npm ci --omit=dev', 'USER node', 'HEALTHCHECK', 'ENTRYPOINT']) expect(dockerfile, marker); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', 'vendoo_db:', 'vendoo_uploads:', 'vendoo_backups:', '/readyz', 'pids_limit: 256', 'max-size: "10m"']) expect(compose, marker); +for (const marker of ['VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'VENDOO_COOKIE_SAMESITE=', 'VENDOO_EXTENSION_ORIGINS=']) expect(env, marker); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child.replaceAll('\\', '/')); + } + return out; +} +const forbidden = walk(root).filter(rel => + /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|operations|logs|runtime|data)(\/|$)/i.test(rel) + && rel !== '.env.example' +); +if (forbidden.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbidden.slice(0, 10).join(', ')}`); + +if (failures.length) { + console.error(`Deployment-Gate 1.32.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.32.0 erfolgreich: Versionen, Pfadtrennung, Container-Härtung, Readiness, Shutdown, Setup-Schutz und Datenhygiene geprüft.'); diff --git a/tools/verify-deployment-1.33.0.mjs b/tools/verify-deployment-1.33.0.mjs new file mode 100644 index 0000000..d65b55c --- /dev/null +++ b/tools/verify-deployment-1.33.0.mjs @@ -0,0 +1,92 @@ +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const auth = read('lib/auth.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const dockerfile = read('Dockerfile'); +const compose = read('compose.yaml'); +const env = read('.env.example'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const setupPs = read('setup.ps1'); +const setupSh = read('setup.sh'); +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.33.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.33.0"', 'style.css?v=1.33.0', 'app.js?v=1.33.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.33.0';")) failures.push('UI-Build ist nicht 1.33.0'); +for (const marker of ["$Version = '1.33.0'", '?build=1.33.0']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + "app.get('/healthz'", "app.get('/readyz'", 'gracefulShutdown', "process.once('SIGTERM'", + 'verifyRuntimeWritable', 'VENDOO_SETUP_TOKEN', 'VENDOO_EXTENSION_ORIGINS', 'setup_required: true', + "app.use('/uploads', requireAuth", 'allowed.includes(ext) && isImageMime', +]) expect(server, marker); +for (const marker of ['VENDOO_DATA_DIR', 'VENDOO_DB_PATH', 'VENDOO_UPLOADS_DIR', 'VENDOO_BACKUP_DIR', 'VENDOO_OPERATIONS_DIR']) expect(runtimePaths, marker); +forbid(server, 'if (isSetupMode()) return next();', 'unsichere globale Setup-Auth-Ausnahme'); + +for (const marker of ['FROM node:22-bookworm-slim', 'npm ci --omit=dev', 'USER node', 'HEALTHCHECK', 'ENTRYPOINT']) expect(dockerfile, marker); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', 'vendoo_db:', 'vendoo_uploads:', 'vendoo_backups:', '/readyz', 'pids_limit: 256', 'max-size: "10m"', 'host.docker.internal:host-gateway']) expect(compose, marker); +for (const marker of ['VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'VENDOO_COOKIE_SAMESITE=', 'VENDOO_EXTENSION_ORIGINS=']) expect(env, marker); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); + +for (const marker of [ + "Wo soll Vendoo betrieben werden?", 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'NAS / Portainer / Docker-Server', 'VPS / öffentlicher Linux-Server', 'Install-VendooDocker', + "Get-VendooInstallMode", "Set-VendooInstallMode 'docker'", +]) expect(setupPs, marker, `Windows-Deployment-Assistent ${marker}`); + +if (!existsSync(join(root, 'setup.sh'))) failures.push('setup.sh fehlt'); +for (const marker of [ + 'Multiplatform Deployment Assistant', 'Docker auf diesem Linux-/Docker-Host', + 'NAS / Portainer / Docker-Host', 'VPS / öffentlicher Server hinter HTTPS-Reverse-Proxy', + 'compose build --pull', 'VENDOO_SETUP_TOKEN', 'docker-backup.sh', 'docker-restore.sh', +]) expect(setupSh, marker, `Linux-Deployment-Assistent ${marker}`); + +for (const marker of [ + 'id="flux-studio-reset"', 'Arbeitsbereich zurücksetzen', 'id="edit-user-email"', + 'type="button" class="primary-btn" id="edit-user-save-btn"', +]) expect(html, marker, `UI-Fix ${marker}`); +for (const marker of [ + 'function resetFluxStudioWorkspace', "renderFluxStudioPreview(null)", + "document.getElementById('flux-studio-reset')", 'data-user-edit=', + 'adminUsersCache', "button.textContent = 'Wird gespeichert …'", +]) expect(app, marker, `Frontend-Fix ${marker}`); +forbid(app, 'if (!state.fluxStudioCurrent && state.fluxStudioHistory[0])', 'automatische Auswahl des ersten FLUX-Historienbilds'); +forbid(app, 'onclick="openEditUser(', 'fragile Inline-Benutzerbearbeitung'); +for (const marker of ['return getUsers().find(user => Number(user.id) === Number(id)) || null;']) expect(auth, marker, 'sanitisierte Benutzerantwort nach Update'); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child.replaceAll('\\', '/')); + } + return out; +} +const forbiddenRuntime = walk(root).filter(rel => + /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|operations|logs|runtime|data)(\/|$)/i.test(rel) + && rel !== '.env.example' +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +if (failures.length) { + console.error(`Deployment-Gate 1.33.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.33.0 erfolgreich: Multiplatform-Assistent, Container-Härtung, leerer FLUX-Start, Reset und stabile Benutzerbearbeitung geprüft.'); diff --git a/tools/verify-deployment-1.34.0.mjs b/tools/verify-deployment-1.34.0.mjs new file mode 100644 index 0000000..a8f9c5b --- /dev/null +++ b/tools/verify-deployment-1.34.0.mjs @@ -0,0 +1,109 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.0"', 'style.css?v=1.34.0', 'app.js?v=1.34.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.0';")) failures.push('UI-Build ist nicht 1.34.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.0'", '?build=1.34.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.0-initial-import', 'verify-git-safety.mjs', 'robocopy.exe', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/3']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.0 erfolgreich: Zero-Edit-Installer, VPS-HTTPS, Git-Sicherheitsgate, CI, Release-Paket und GHCR-Workflow geprüft.'); diff --git a/tools/verify-deployment-1.34.1.mjs b/tools/verify-deployment-1.34.1.mjs new file mode 100644 index 0000000..1b69181 --- /dev/null +++ b/tools/verify-deployment-1.34.1.mjs @@ -0,0 +1,112 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.1') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.1"', 'style.css?v=1.34.1', 'app.js?v=1.34.1']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.1';")) failures.push('UI-Build ist nicht 1.34.1'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.1'", '?build=1.34.1', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.1 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.1-initial-import', 'verify-git-safety.mjs', 'robocopy.exe', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/4']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.1 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.1 erfolgreich: Windows-sicheres Git-Gate, vollständiger Arbeitsbaumscan, Regressionstest, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/tools/verify-deployment-1.34.2.mjs b/tools/verify-deployment-1.34.2.mjs new file mode 100644 index 0000000..4897800 --- /dev/null +++ b/tools/verify-deployment-1.34.2.mjs @@ -0,0 +1,116 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.2') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.2"', 'style.css?v=1.34.2', 'app.js?v=1.34.2']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.2';")) failures.push('UI-Build ist nicht 1.34.2'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.2'", '?build=1.34.2', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.2 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.2-initial-import', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/5']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.2 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.2 erfolgreich: gemeinsamer Git-Staging-Builder, vollständige Windows-Diagnose, Arbeitsbaumscan, Regressionstests, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/tools/verify-deployment-1.34.3.mjs b/tools/verify-deployment-1.34.3.mjs new file mode 100644 index 0000000..73e87ed --- /dev/null +++ b/tools/verify-deployment-1.34.3.mjs @@ -0,0 +1,116 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.34.3') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.34.3"', 'style.css?v=1.34.3', 'app.js?v=1.34.3']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.34.3';")) failures.push('UI-Build ist nicht 1.34.3'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.34.3'", '?build=1.34.3', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.34.3 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.34.3-initial-import', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/6']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.34.3 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.34.3 erfolgreich: lokale Update-/Slice-Archive vollständig aus Git-Staging und Release-Paketen ausgeschlossen; aktive Installationsskripte, Arbeitsbaumscan, Regressionstests, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/tools/verify-deployment-1.35.0.mjs b/tools/verify-deployment-1.35.0.mjs new file mode 100644 index 0000000..7d13ab9 --- /dev/null +++ b/tools/verify-deployment-1.35.0.mjs @@ -0,0 +1,129 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); +const architectureGate = read('tools/verify-platform-architecture-1.35.0.mjs'); +const tokenSource = read('public/design-system/tokens/source.json'); +const tokenCss = read('public/design-system/tokens.css'); +const moduleSchema = read('app/contracts/module.schema.json'); +const boundaries = read('app/architecture-boundaries.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.35.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.35.0"', 'style.css?v=1.35.0', 'app.js?v=1.35.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.35.0';")) failures.push('UI-Build ist nicht 1.35.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.35.0'", '?build=1.35.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.35.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.35.0-platform-kernel', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/7']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); + +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) expect(read('server.mjs'), marker, `Platform-Kernel ${marker}`); +for (const marker of ['Platform-Architecture-Gate 1.35.0 erfolgreich', 'MODULE_DEPENDENCY_CYCLE', 'ROUTE_POLICY_REQUIRED', 'THEME_VALUE_INVALID']) expect(architectureGate, marker, `Architektur-Gate ${marker}`); +for (const marker of ['contractVersion', 'color.brand', 'density.controlHeight', 'motion.normal']) expect(tokenSource, marker, `Tokenquelle ${marker}`); +for (const marker of [':root {', '[data-theme="dark"]', '[data-theme="contrast"]', '--vd-brand:']) expect(tokenCss, marker, `Token-CSS ${marker}`); +for (const marker of ['Vendoo Module Manifest', 'legacy-bridge', 'extension-host']) expect(moduleSchema, marker, `Modulschema ${marker}`); +for (const marker of ['modular-monolith', 'denyImplicitRoutes', 'thirdPartyExtensionsInMainProcess', 'strangler']) expect(boundaries, marker, `Architekturgrenzen ${marker}`); +for (const required of ['app/kernel/platform-kernel.mjs', 'app/kernel/module-registry.mjs', 'app/kernel/route-registry.mjs', 'app/kernel/policy-engine.mjs', 'app/core/themes/theme-contract.mjs', 'public/design-system/theme-runtime.js', 'public/design-system/theme-contract.json', 'public/core/frontend-module-registry.js', 'public/core/safe-dom.js', 'public/core/api-client.js', 'docs/architecture/MODULE_SYSTEM_1_35_0.md', 'docs/architecture/DESIGN_SYSTEM_1_35_0.md', 'docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md']) if (!existsSync(join(root, required))) failures.push(`Architektur-Pflichtdatei fehlt: ${required}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.35.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.35.0 erfolgreich: Plattformkernel, Modul-/Policy-/Routenverträge, Design Tokens, Legacy-Bridges, Update-Archiv-Ausschluss, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/tools/verify-deployment-1.36.0.mjs b/tools/verify-deployment-1.36.0.mjs new file mode 100644 index 0000000..a29e84b --- /dev/null +++ b/tools/verify-deployment-1.36.0.mjs @@ -0,0 +1,133 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; +const forbid = (text, marker, label = marker) => { if (text.includes(marker)) failures.push(`Darf nicht enthalten sein: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const html = read('public/index.html'); +const app = read('public/app.js'); +const bootstrap = read('bootstrap.mjs'); +const configStore = read('lib/config-store.mjs'); +const runtimePaths = read('lib/runtime-paths.mjs'); +const setupPs = read('setup.ps1'); +const setupGui = read('setup-gui.ps1'); +const setupBat = read('setup.bat'); +const setupSh = read('setup.sh'); +const compose = read('compose.yaml'); +const composeVps = read('compose.vps.yaml'); +const caddy = read('deploy/Caddyfile'); +const env = read('.env.example'); +const gitignore = read('.gitignore'); +const ci = read('.github/workflows/ci.yml'); +const release = read('.github/workflows/release.yml'); +const gitDeploy = read('scripts/github-deploy.ps1'); +const gitSafety = read('tools/verify-git-safety.mjs'); +const gitSafetyRegression = read('tools/verify-git-safety-regression.mjs'); +const releaseBuilder = read('tools/build-release.mjs'); +const installerShellGate = read('tools/verify-installer-shell.sh'); +const gitStaging = read('tools/prepare-git-staging.mjs'); +const gitStagingRegression = read('tools/verify-git-staging.mjs'); +const architectureGate = read('tools/verify-platform-architecture-1.36.0.mjs'); +const themeGate = read('tools/verify-theme-manager-1.36.0.mjs'); +const themeManager = read('public/modules/theme-manager/theme-manager.js'); +const tokenSource = read('public/design-system/tokens/source.json'); +const tokenCss = read('public/design-system/tokens.css'); +const moduleSchema = read('app/contracts/module.schema.json'); +const boundaries = read('app/architecture-boundaries.json'); + +for (const [label, value] of [ + ['package.json', pkg.version], ['package-lock.json', lock.version], + ['package-lock root', lock.packages?.['']?.version], ['update-manifest', manifest.version], +]) if (value !== '1.36.0') failures.push(`${label}-Version ist ${value}`); + +for (const marker of ['content="1.36.0"', 'style.css?v=1.36.0', 'app.js?v=1.36.0']) expect(html, marker, `UI-Build ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.36.0';")) failures.push('UI-Build ist nicht 1.36.0'); +for (const marker of ['VENDOO_CONFIG_PATH', "await import('./server.mjs')", 'override: true']) expect(bootstrap, marker, `Bootstrap ${marker}`); +for (const marker of ['CONFIG_PATH', 'writeRuntimeConfig', 'updateRuntimeConfig', 'mode: 0o600']) expect(configStore, marker, `Konfigurationsspeicher ${marker}`); +for (const marker of ['VENDOO_CONFIG_PATH', 'runtime_config: CONFIG_PATH']) expect(runtimePaths, marker, `Runtime-Pfade ${marker}`); +for (const marker of ['readRuntimeConfig()', 'writeRuntimeConfig(envContent)', 'updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token })', 'runtime_config_path: CONFIG_PATH']) expect(read('server.mjs'), marker, `Server-Konfiguration ${marker}`); +forbid(read('server.mjs'), "join(__dirname, '.env')", 'direktes Schreiben in schreibgeschütztes App-.env'); + +for (const marker of ["$Version = '1.36.0'", '?build=1.36.0', "'install-local'", "'install-docker'", 'Configure-VendooConsole', 'Initialize-VendooEnvironment']) expect(setupPs, marker, `Windows-Setup ${marker}`); + +for (const marker of [ + 'Vendoo 1.36.0 – Installation', 'Lokal auf diesem Windows-PC', 'Docker Desktop auf diesem Windows-PC', + 'Set-Configuration', 'VENDOO_SETUP_TOKEN', 'Clipboard', 'AI- und Marktplatz-Schlüssel werden später', +]) expect(setupGui, marker, `Windows-GUI ${marker}`); +forbid(setupGui, 'PlaceholderText', 'nicht mit Windows PowerShell 5.1 kompatible PlaceholderText-Eigenschaft'); +for (const marker of ['setup-gui.ps1', '-STA', 'Parser]::ParseFile']) expect(setupBat, marker, `setup.bat ${marker}`); + +for (const marker of [ + 'Zero-Edit Deployment Assistant', 'configure_wizard', 'VPS / öffentlicher Server mit automatischem HTTPS', + 'VENDOO_DOMAIN', 'compose.vps.yaml', 'Caddy übernimmt HTTPS automatisch', 'keine Datei musste manuell bearbeitet werden', + 'Aktualisieren (mit Sicherheitsbackup)', 'docker-backup.sh', +]) expect(setupSh, marker, `Linux-Assistent ${marker}`); + +for (const marker of ['VENDOO_CONFIG_PATH: /app/data/config/vendoo.env', 'vendoo_config:/app/data/config']) expect(compose, marker, `Basis-Compose-Konfiguration ${marker}`); +for (const marker of ['caddy:2-alpine', '80:80', '443:443', './deploy/Caddyfile', 'condition: service_healthy', 'read_only: true', 'vendoo_config:/app/data/config']) expect(composeVps, marker, `VPS-Compose ${marker}`); +for (const marker of ['{$VENDOO_DOMAIN}', 'reverse_proxy vendoo:8124', 'Strict-Transport-Security']) expect(caddy, marker, `Caddy ${marker}`); +for (const marker of ['VENDOO_DOMAIN=', 'VENDOO_SETUP_TOKEN=', 'VENDOO_COOKIE_SECURE=', 'LOCAL_IMAGE_ENABLED=false']) expect(env, marker, `.env.example ${marker}`); + +for (const marker of ['name: Vendoo CI', 'npm ci', 'npm run verify:git', 'npm run verify:git-regression', 'npm run verify:git-staging', 'npm run verify:architecture', 'npm run verify:themes', 'npm run verify:deployment', 'npm run verify:config', 'npm run verify:installer-shell', 'docker build --pull', 'compose.vps.yaml']) expect(ci, marker, `CI ${marker}`); +for (const marker of ['name: Vendoo Release', 'Release-Tag gegen Paketversion prüfen', 'npm run verify:git-staging', 'npm run verify:architecture', 'npm run verify:themes', 'npm run verify:config', 'npm run verify:installer-shell', 'release-output', 'gh release upload', 'ghcr.io', 'docker/build-push-action@v6', 'actions/attest@v4', 'attestations: write', 'id-token: write', 'steps.push.outputs.digest', 'linux/amd64,linux/arm64']) expect(release, marker, `Release-Workflow ${marker}`); +for (const marker of ['release/1.36.0-theme-manager', 'verify-git-safety.mjs', 'prepare-git-staging.mjs', 'RedirectStandardOutput', 'github-deploy-safety-report.txt', 'Zum Fortfahren PUSH eingeben', 'vendoo/pull/9']) expect(gitDeploy, marker, `GitHub-Deploy ${marker}`); +for (const marker of ['fileURLToPath', 'kompletten Arbeitsbaum', 'forbiddenPathPatterns', 'secretPatterns', 'Git-Sicherheitsgate erfolgreich']) expect(gitSafety, marker, `Git-Sicherheitsgate ${marker}`); +for (const marker of ['unversioniert', '.env.local', 'untracked-secret.txt', 'Git-Sicherheitsgate-Regressionstest erfolgreich']) expect(gitSafetyRegression, marker, `Git-Sicherheitsgate-Regression ${marker}`); +for (const marker of ['prepareGitStaging', 'shouldSkipStagingEntry', 'allowedEnvTemplates', 'skippedTopLevelDirs', 'operations', 'updates', 'Ziel ist kein geklontes Git-Repository']) expect(gitStaging, marker, `Git-Staging ${marker}`); +for (const marker of ['.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/setup.ps1', '.git/marker', 'Git-Staging-Regressionstest erfolgreich']) expect(gitStagingRegression, marker, `Git-Staging-Regression ${marker}`); +for (const marker of ['release-output', 'FILE_CHECKSUMS_SHA256.txt', 'createHash', 'shouldSkipStagingEntry']) expect(releaseBuilder, marker, `Release-Builder ${marker}`); +for (const marker of ['run_mode docker', 'run_mode nas', 'run_mode vps', 'VENDOO_SETUP_TOKEN', 'stat -c']) expect(installerShellGate, marker, `Shell-Installer-Gate ${marker}`); + +for (const marker of ['.env', 'runtime/', 'updates/', 'local-ai/comfyui/', '.vendoo-install-mode', 'release-output/']) expect(gitignore, marker, `.gitignore ${marker}`); + +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) expect(read('server.mjs'), marker, `Platform-Kernel ${marker}`); +for (const marker of ['Platform-Architecture-Gate 1.36.0 erfolgreich', 'MODULE_DEPENDENCY_CYCLE', 'ROUTE_POLICY_REQUIRED']) expect(architectureGate, marker, `Architektur-Gate ${marker}`); +for (const marker of ['Theme-Manager-Gate 1.36.0 erfolgreich', 'THEME_VALUE_INVALID', 'theme_profiles', 'user_theme_preferences']) expect(themeGate, marker, `Theme-Gate ${marker}`); +for (const marker of ['/api/themes/preference', '/api/themes/system-default', 'previewTheme']) expect(themeManager, marker, `Theme Manager ${marker}`); +for (const marker of ['contractVersion', 'color.brand', 'density.controlHeight', 'motion.normal']) expect(tokenSource, marker, `Tokenquelle ${marker}`); +for (const marker of [':root {', '[data-theme="dark"]', '[data-theme="contrast"]', '--vd-brand:']) expect(tokenCss, marker, `Token-CSS ${marker}`); +for (const marker of ['Vendoo Module Manifest', 'legacy-bridge', 'extension-host']) expect(moduleSchema, marker, `Modulschema ${marker}`); +for (const marker of ['modular-monolith', 'denyImplicitRoutes', 'thirdPartyExtensionsInMainProcess', 'strangler']) expect(boundaries, marker, `Architekturgrenzen ${marker}`); +for (const required of ['app/kernel/platform-kernel.mjs', 'app/kernel/module-registry.mjs', 'app/kernel/route-registry.mjs', 'app/kernel/policy-engine.mjs', 'app/core/themes/theme-contract.mjs', 'public/design-system/theme-runtime.js', 'public/design-system/theme-contract.json', 'public/core/frontend-module-registry.js', 'public/core/safe-dom.js', 'public/core/api-client.js', 'docs/architecture/MODULE_SYSTEM_1_35_0.md', 'docs/architecture/DESIGN_SYSTEM_1_35_0.md', 'docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md', 'docs/architecture/THEME_MANAGER_1_36_0.md']) if (!existsSync(join(root, required))) failures.push(`Architektur-Pflichtdatei fehlt: ${required}`); +for (const marker of ['read_only: true', 'no-new-privileges:true', 'cap_drop:', '/readyz', 'host.docker.internal:host-gateway']) expect(compose, marker, `Basis-Compose ${marker}`); + +for (const required of [ + '.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/dependabot.yml', '.github/CODEOWNERS', + 'GitHub-Deploy-Assistent.bat', 'scripts/github-deploy.ps1', 'scripts/github-deploy.sh', + 'setup-gui.ps1', 'bootstrap.mjs', 'lib/config-store.mjs', 'compose.vps.yaml', 'deploy/Caddyfile', 'tools/verify-git-safety.mjs', 'tools/verify-git-safety-regression.mjs', 'tools/prepare-git-staging.mjs', 'tools/verify-git-staging.mjs', 'tools/verify-installer-shell.sh', 'tools/build-release.mjs', +]) if (!existsSync(join(root, required))) failures.push(`Pflichtdatei fehlt: ${required}`); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'release-output' || name === '.git') continue; + const abs = join(dir, name); + const rel = relative(root, abs).split(sep).join('/'); + const stat = lstatSync(abs); + if (stat.isDirectory()) walk(abs, out); else out.push(rel); + } + return out; +} +const files = walk(root); +const forbiddenRuntime = files.filter(rel => + rel === '.env' || rel === '.vendoo-install-mode' || + /(^|\/)(runtime|node_modules|uploads|backups|logs|updates)(\/|$)/i.test(rel) || + /^db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i.test(rel) || + /^local-ai\/(comfyui|downloads|extract-temp)\//i.test(rel) +); +if (forbiddenRuntime.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbiddenRuntime.slice(0, 10).join(', ')}`); + +forbid(read('README.md'), '.env.example` nach `.env` kopieren', 'alte manuelle .env-Anweisung im README'); + +if (failures.length) { + console.error(`Deployment-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log('Deployment-Gate 1.36.0 erfolgreich: Plattformkernel, natives Theme-Modul, Theme Manager, Accessibility-Gates, Design Tokens, Legacy-Bridges, Update-Archiv-Ausschluss, Zero-Edit-Installer, CI und Release-Workflow geprüft.'); diff --git a/tools/verify-git-safety-regression.mjs b/tools/verify-git-safety-regression.mjs new file mode 100644 index 0000000..7b779aa --- /dev/null +++ b/tools/verify-git-safety-regression.mjs @@ -0,0 +1,40 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const tempRoot = mkdtempSync(join(tmpdir(), 'vendoo-git-gate-regression-')); +const testRoot = join(tempRoot, 'repo'); +mkdirSync(join(testRoot, 'tools'), { recursive: true }); +cpSync(join(sourceRoot, 'tools', 'verify-git-safety.mjs'), join(testRoot, 'tools', 'verify-git-safety.mjs')); +writeFileSync(join(testRoot, '.gitignore'), '.env\nnode_modules/\nruntime/\noperations/pending-operation.json\nlocal-ai/comfyui/\n.vendoo-install-mode\n', 'utf8'); +writeFileSync(join(testRoot, 'package.json'), '{"type":"module"}\n', 'utf8'); + +try { + execFileSync('git', ['init'], { cwd: testRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '.gitignore', 'package.json', 'tools/verify-git-safety.mjs'], { cwd: testRoot, stdio: 'ignore' }); + + // Entscheidend: Diese Dateien bleiben absichtlich unversioniert. + const openAiLike = 'sk-' + 'this-is-a-regression-test-secret-1234567890'; + writeFileSync(join(testRoot, '.env.local'), `OPENAI_API_KEY=${openAiLike}\n`, 'utf8'); + const githubLike = 'github_' + 'pat_' + 'abcdefghijklmnopqrstuvwxyz0123456789TEST'; + writeFileSync(join(testRoot, 'untracked-secret.txt'), `${githubLike}\n`, 'utf8'); + + const blocked = spawnSync(process.execPath, ['tools/verify-git-safety.mjs'], { cwd: testRoot, encoding: 'utf8' }); + if (blocked.status === 0) throw new Error('Gate hat unversionierte Secret-Dateien nicht blockiert.'); + const report = `${blocked.stdout || ''}\n${blocked.stderr || ''}`; + if (!report.includes('.env.local') || !report.includes('untracked-secret.txt')) { + throw new Error(`Gate-Bericht nennt nicht alle unversionierten Treffer:\n${report}`); + } + + rmSync(join(testRoot, '.env.local')); + rmSync(join(testRoot, 'untracked-secret.txt')); + const clean = spawnSync(process.execPath, ['tools/verify-git-safety.mjs'], { cwd: testRoot, encoding: 'utf8' }); + if (clean.status !== 0) throw new Error(`Gate lehnt sauberen Arbeitsbaum ab:\n${clean.stdout}\n${clean.stderr}`); + + console.log('Git-Sicherheitsgate-Regressionstest erfolgreich: unversionierte Secrets werden blockiert, sauberer Arbeitsbaum wird akzeptiert.'); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/tools/verify-git-safety.mjs b/tools/verify-git-safety.mjs new file mode 100644 index 0000000..840a10a --- /dev/null +++ b/tools/verify-git-safety.mjs @@ -0,0 +1,103 @@ +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { extname, join, relative, sep, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const failures = []; +const warnings = []; +const normalize = value => value.split(sep).join('/').replace(/^\.\//, ''); + +const forbiddenPathPatterns = [ + /(^|\/)node_modules(\/|$)/i, + /(^|\/)runtime(\/|$)/i, + /(^|\/)docker-data(\/|$)/i, + /(^|\/)release-output(\/|$)/i, + /(^|\/)operations\/(staging|updates|rollback|apply-[^/]+)(\/|$)/i, + /(^|\/)operations\/(pending-operation|last-operation|first-admin-code)\.json$/i, + /(^|\/)operations\/first-admin-code\.txt$/i, + /(^|\/)(uploads|backups|logs|sessions|tmp|temp)(\/|$)/i, + /(^|\/)updates(\/|$)/i, + /(^|\/)db\/.*\.(db|sqlite|sqlite3)(-wal|-shm)?$/i, + /(^|\/)local-ai\/(comfyui|downloads|extract-temp)(\/|$)/i, + /(^|\/)\.vendoo-install-mode$/i, + /(^|\/)\.vendoo-github-state\.json$/i, +]; + +const allowedEnv = new Set(['.env.example', '.env.docker.example']); +const binaryExt = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.zip', '.pdf', '.woff', '.woff2']); +const secretPatterns = [ + ['OpenAI-/kompatibler Schlüssel', /\bsk-[A-Za-z0-9_-]{20,}\b/g], + ['GitHub Classic Token', /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g], + ['GitHub Fine-grained Token', /\bgithub_pat_[A-Za-z0-9_]{30,}\b/g], + ['Slack Token', /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g], + ['Private Key', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g], + ['AWS Access Key', /\bAKIA[0-9A-Z]{16}\b/g], +]; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === '.git' || name === 'node_modules' || name === 'release-output') continue; + const abs = join(dir, name); + const rel = normalize(relative(root, abs)); + const stat = lstatSync(abs); + if (stat.isSymbolicLink()) { + warnings.push(`Symlink wird nicht geprüft oder gepackt: ${rel}`); + continue; + } + if (stat.isDirectory()) walk(abs, out); + else out.push(rel); + } + return out; +} + +// Absichtlich immer den kompletten Arbeitsbaum prüfen. Ein Git-Erstimport enthält +// viele noch unversionierte Dateien; "git ls-files" würde diese nicht erfassen. +const files = walk(root); +for (const rel of files) { + const envLike = /(^|\/)\.env(?:\..+)?$/i.test(rel); + if (envLike && !allowedEnv.has(rel)) failures.push(`Verbotene Environment-Datei: ${rel}`); + if (!allowedEnv.has(rel) && forbiddenPathPatterns.some(pattern => pattern.test(rel))) { + failures.push(`Verbotene Laufzeit-/Secret-Datei: ${rel}`); + } + + const abs = join(root, rel); + if (!existsSync(abs)) continue; + const stat = lstatSync(abs); + if (stat.size > 50 * 1024 * 1024) failures.push(`Datei größer als 50 MiB: ${rel}`); + if (binaryExt.has(extname(rel).toLowerCase()) || stat.size > 5 * 1024 * 1024) continue; + + let text; + try { text = readFileSync(abs, 'utf8'); } catch { continue; } + for (const [label, pattern] of secretPatterns) { + pattern.lastIndex = 0; + if (pattern.test(text)) failures.push(`${label} in ${rel}`); + } + + if (!allowedEnv.has(rel)) { + const envAssignment = /^(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY|ETSY_API_KEY|EBAY_CLIENT_SECRET|SMTP_PASS|VENDOO_SETUP_TOKEN|LOCAL_IMAGE_TOKEN)=(.+)$/gm; + for (const match of text.matchAll(envAssignment)) { + const value = String(match[1] || '').trim(); + if (value && !/^\$\{|^<|^CHANGEME$/i.test(value)) failures.push(`Befülltes Secret-Feld in ${rel}: ${match[0].split('=')[0]}`); + } + } +} + +const gitignorePath = join(root, '.gitignore'); +if (!existsSync(gitignorePath)) { + failures.push('.gitignore fehlt.'); +} else { + const gitignore = readFileSync(gitignorePath, 'utf8'); + for (const marker of ['.env', 'node_modules/', 'runtime/', 'operations/pending-operation.json', 'local-ai/comfyui/', '.vendoo-install-mode']) { + if (!gitignore.includes(marker)) failures.push(`.gitignore enthält nicht: ${marker}`); + } +} + +const uniqueFailures = [...new Set(failures)]; +if (uniqueFailures.length) { + console.error(`Git-Sicherheitsgate fehlgeschlagen (${uniqueFailures.length}):`); + uniqueFailures.slice(0, 100).forEach(item => console.error(`- ${item}`)); + if (uniqueFailures.length > 100) console.error(`- ... ${uniqueFailures.length - 100} weitere Treffer`); + process.exit(1); +} +console.log(`Git-Sicherheitsgate erfolgreich: ${files.length} Dateien geprüft; keine Secrets oder Laufzeitdaten gefunden.`); +warnings.forEach(item => console.warn(`[WARN] ${item}`)); diff --git a/tools/verify-git-staging.mjs b/tools/verify-git-staging.mjs new file mode 100644 index 0000000..bc83b43 --- /dev/null +++ b/tools/verify-git-staging.mjs @@ -0,0 +1,49 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { prepareGitStaging } from './prepare-git-staging.mjs'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-git-staging-')); +const source = join(temp, 'source'); +const destination = join(temp, 'repo'); +const write = (rel, value = rel) => { + const target = join(source, rel); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, value, 'utf8'); +}; + +try { + mkdirSync(source, { recursive: true }); + mkdirSync(join(destination, '.git'), { recursive: true }); + writeFileSync(join(destination, '.git', 'marker'), 'keep', 'utf8'); + writeFileSync(join(destination, 'old-file.txt'), 'remove', 'utf8'); + + write('package.json', '{"name":"vendoo"}'); + write('.env.example', 'OPENAI_API_KEY='); + write('.env.docker.example', 'VENDOO_SETUP_TOKEN='); + write('.env.local', 'OPENAI_API_KEY=local-secret-should-never-copy'); + write('operations/staging/job.json', '{}'); + write('updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1', 'Write-Host local-backup'); + write('updates/backups/slice22_1_20260707-194953/setup.bat', '@echo local-backup'); + write('updates/backups/slice22_1_20260707-194953/setup.ps1', 'Write-Host local-backup'); + write('logs/server.log', 'secret'); + write('uploads/image.jpg', 'binary'); + write('db/vendoo.db', 'database'); + write('local-ai/comfyui/models/model.safetensors', 'model'); + write('docs/readme.md', '# safe'); + + const result = prepareGitStaging(source, destination); + const required = ['package.json', '.env.example', '.env.docker.example', 'docs/readme.md', '.git/marker']; + const forbidden = ['old-file.txt', '.env.local', 'operations/staging/job.json', 'updates/backups/slice22_1_20260707-194953/scripts/install-local-flux.ps1', 'updates/backups/slice22_1_20260707-194953/setup.bat', 'updates/backups/slice22_1_20260707-194953/setup.ps1', 'logs/server.log', 'uploads/image.jpg', 'db/vendoo.db', 'local-ai/comfyui/models/model.safetensors']; + for (const rel of required) { + if (!existsSync(join(destination, rel))) throw new Error(`Pflichtdatei fehlt im Staging: ${rel}`); + } + for (const rel of forbidden) { + if (existsSync(join(destination, rel))) throw new Error(`Verbotene Datei wurde kopiert: ${rel}`); + } + if (readFileSync(join(destination, '.git', 'marker'), 'utf8') !== 'keep') throw new Error('.git wurde verändert.'); + if (result.copied < 4 || result.skipped < 7) throw new Error('Staging-Zähler sind unplausibel.'); + console.log(`Git-Staging-Regressionstest erfolgreich: ${result.copied} sichere Dateien kopiert, ${result.skipped} lokale Einträge ausgeschlossen.`); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-hotfix-1.23.1.mjs b/tools/verify-hotfix-1.23.1.mjs new file mode 100644 index 0000000..f38205b --- /dev/null +++ b/tools/verify-hotfix-1.23.1.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { + if (!condition) throw new Error(message); +}; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const setup = read('setup.ps1'); + +assert(index.includes('name="vendoo-build" content="1.23.1"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.23.1'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.23.1'), 'JS-Cache-Buster fehlt.'); +assert(index.includes('id="advanced-image-editor-btn"'), 'Sichtbarer Editor-Einstieg fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.23.1'"), 'UI-Buildkennung fehlt.'); +assert(app.includes("openPhotoEditor(0, 'generator')"), 'Editor-Button ist nicht angebunden.'); +assert(style.includes('.advanced-editor-entry'), 'Editor-Button-Styling fehlt.'); +assert(server.includes("Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate"), 'No-Store-Header fehlt.'); +assert(setup.includes('/?build=1.23.1'), 'Windows-Start-Cache-Buster fehlt.'); + +const extensionFiles = [ + 'extension/content.js', + 'extensions/content.js', + 'extensions/chrome/content.js', + 'extensions/edge/content.js', + 'extensions/firefox/content.js', +]; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function getAssociatedControl(label, selector)'), `${file}: strikte Label-Zuordnung fehlt.`); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Titelfeldschutz fehlt.`); + assert(content.includes('if (!trigger || isLikelyTitleField(trigger)) return false;'), `${file}: Choice-Felder schützen Titel nicht.`); + assert(!content.includes("label.parentElement?.querySelector('button,[role=\"button\"],select,input')"), `${file}: unsicherer Eltern-Fallback noch vorhanden.`); + assert(!content.includes("'[class*=\"Title\"] input'"), `${file}: unspezifischer Title-Klassenfallback noch vorhanden.`); +} + +const manifests = [ + 'extension/manifest.json', + 'extensions/chrome/manifest.json', + 'extensions/edge/manifest.json', + 'extensions/firefox/manifest.json', + 'extensions/manifest.chromium.json', + 'extensions/manifest.firefox.json', +]; +for (const file of manifests) { + const manifest = JSON.parse(read(file)); + assert(manifest.version === '1.6.1', `${file}: erwartete Extension-Version 1.6.1.`); +} + +console.log('Vendoo 1.23.1 Hotfix-Verifikation erfolgreich.'); diff --git a/tools/verify-hotfix-1.23.2.mjs b/tools/verify-hotfix-1.23.2.mjs new file mode 100644 index 0000000..ec60302 --- /dev/null +++ b/tools/verify-hotfix-1.23.2.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const editor = read('lib/image-editor.mjs'); +const setup = read('setup.ps1'); + +assert(index.includes('name="vendoo-build" content="1.23.2"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.23.2'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.23.2'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.23.2'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.23.2'), 'Windows-Start-Cache-Buster fehlt.'); + +const promptIndex = index.indexOf('

                                                                      Prompt & Auftrag

                                                                      '); +const runtimeIndex = index.indexOf('

                                                                      System & ComfyUI

                                                                      '); +const previewIndex = index.indexOf('

                                                                      Auswahl & Varianten

                                                                      '); +assert(promptIndex >= 0 && runtimeIndex > promptIndex && previewIndex > runtimeIndex, 'FLUX-Kartenreihenfolge ist nicht Prompt → System → Vorschau.'); +assert(index.includes('class="flux-studio-left-stack"'), 'Linker FLUX-Stack fehlt.'); +assert(index.includes('id="flux-job-center-toggle"'), 'Job-Center-Gesamttoggle fehlt.'); +assert(index.includes('id="flux-job-collapse-all"'), 'Alle-einklappen-Schalter fehlt.'); +assert(app.includes('data-flux-job-toggle'), 'Einzelne Job-Toggles fehlen.'); +assert(style.includes('.flux-job-card.is-collapsed .flux-job-card-body'), 'Job-Collapse-Styling fehlt.'); + +assert(index.includes('id="pe-remove-background"'), 'Hintergrundentfernung fehlt im Editor.'); +assert(index.includes('id="pe-watermark-enabled"'), 'Wasserzeichen fehlt im Editor.'); +assert(index.includes('id="pe-watermark-text"'), 'Wasserzeichentext fehlt.'); +assert(!index.includes('id="remove-bg-btn"'), 'Alter Hintergrund-Button ist noch vorhanden.'); +assert(!index.includes('id="watermark-btn"'), 'Alter Wasserzeichen-Button ist noch vorhanden.'); +assert(!app.includes("getElementById('remove-bg-btn')"), 'Alter Hintergrund-Handler ist noch vorhanden.'); +assert(!app.includes("getElementById('watermark-btn')"), 'Alter Wasserzeichen-Handler ist noch vorhanden.'); +assert(editor.includes('removeUniformBackground'), 'Serverseitige Hintergrundentfernung fehlt.'); +assert(editor.includes('createWatermarkSvg'), 'Serverseitiges Wasserzeichen fehlt.'); +assert(app.includes('applyPreviewBackgroundRemoval'), 'Hintergrundvorschau fehlt.'); +assert(app.includes('applyPreviewWatermark'), 'Wasserzeichenvorschau fehlt.'); + +const extensionFiles = [ + 'extension/content.js', + 'extensions/content.js', + 'extensions/chrome/content.js', + 'extensions/edge/content.js', + 'extensions/firefox/content.js', +]; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Titelfeldschutz fehlt.`); + assert(content.includes('if (!trigger || isLikelyTitleField(trigger)) return false;'), `${file}: Choice-Felder schützen Titel nicht.`); +} + +console.log('Vendoo 1.23.2 Hotfix-Verifikation erfolgreich.'); diff --git a/tools/verify-installer-shell.sh b/tools/verify-installer-shell.sh new file mode 100644 index 0000000..a0ba38a --- /dev/null +++ b/tools/verify-installer-shell.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP_DIR=$(mktemp -d) +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT INT TERM + +mkdir -p "$TMP_DIR/bin" +cat > "$TMP_DIR/bin/docker" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "docker $*" >> "${FAKE_DOCKER_LOG:?}" +exit 0 +EOF +cat > "$TMP_DIR/bin/curl" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF +chmod +x "$TMP_DIR/bin/docker" "$TMP_DIR/bin/curl" + +run_mode() { + name=$1 + input=$2 + dir="$TMP_DIR/$name" + mkdir -p "$dir" + tar -C "$ROOT_DIR" --exclude=node_modules --exclude=release-output -cf - . | tar -C "$dir" -xf - + FAKE_DOCKER_LOG="$dir/docker.log" + export FAKE_DOCKER_LOG + printf '%b' "$input" | PATH="$TMP_DIR/bin:$PATH" sh "$dir/setup.sh" install > "$dir/out.log" 2>&1 + + grep -q 'keine Datei musste manuell bearbeitet werden' "$dir/out.log" + grep -q '^VENDOO_SETUP_TOKEN=[0-9a-f]\{64\}$' "$dir/.env" + grep -q 'docker compose build --pull' "$dir/docker.log" + grep -q 'docker compose up -d --remove-orphans' "$dir/docker.log" + test "$(stat -c '%a' "$dir/.env")" = 600 +} + +run_mode docker '1\n8124\nnein\n' +grep -q '^VENDOO_PUBLISH_ADDRESS=127.0.0.1$' "$TMP_DIR/docker/.env" +grep -q '^VENDOO_BIND_HOST=0.0.0.0$' "$TMP_DIR/docker/.env" + +run_mode nas '2\n9124\nhttp://192.168.1.50:9124\nnein\n' +test -d "$TMP_DIR/nas/runtime/config" +test -d "$TMP_DIR/nas/runtime/db" +grep -q '^VENDOO_PUBLISH_ADDRESS=0.0.0.0$' "$TMP_DIR/nas/.env" +grep -q '^PUBLIC_BASE_URL=http://192.168.1.50:9124$' "$TMP_DIR/nas/.env" + +run_mode vps '3\n8124\nvendoo.example.invalid\nnein\n' +grep -q '^VENDOO_DOMAIN=vendoo.example.invalid$' "$TMP_DIR/vps/.env" +grep -q '^PUBLIC_BASE_URL=https://vendoo.example.invalid$' "$TMP_DIR/vps/.env" +grep -q '^VENDOO_COOKIE_SECURE=true$' "$TMP_DIR/vps/.env" +grep -q '^VENDOO_TRUST_PROXY=true$' "$TMP_DIR/vps/.env" + +printf '%s\n' 'Shell-Installer-Gate erfolgreich: Docker-, NAS- und VPS-Dialog erzeugen valide, geschützte Konfiguration ohne manuelle Dateibearbeitung.' diff --git a/tools/verify-platform-architecture-1.35.0.mjs b/tools/verify-platform-architecture-1.35.0.mjs new file mode 100644 index 0000000..315ab3a --- /dev/null +++ b/tools/verify-platform-architecture-1.35.0.mjs @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'child_process'; +import { createPlatformKernel } from '../app/kernel/platform-kernel.mjs'; +import { ModuleRegistry } from '../app/kernel/module-registry.mjs'; +import { PolicyEngine } from '../app/kernel/policy-engine.mjs'; +import { RouteRegistry } from '../app/kernel/route-registry.mjs'; +import { EventBus } from '../app/kernel/event-bus.mjs'; +import { validateThemeDefinition } from '../app/core/themes/theme-contract.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = async (fn, code, message) => { + try { await fn(); failures.push(message); } + catch (error) { if (code && error.code !== code) failures.push(`${message} (erhalten: ${error.code || error.message})`); } +}; + +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +assert(packageJson.version === '1.35.0', `package.json ist ${packageJson.version}`); + +const kernel = createPlatformKernel({ + version: packageJson.version, + hasPermission: (role, permission) => role === 'admin' && permission === 'security.manage', +}); +await kernel.start(); +const snapshot = kernel.snapshot(); +assert(snapshot.architecture === 'modular-monolith', 'Architekturtyp fehlt'); +assert(snapshot.modules.length === 12, `Erwartet 12 Module, erhalten ${snapshot.modules.length}`); +assert(snapshot.modules.every(module => module.state === 'running'), 'Nicht alle Module laufen'); +assert(snapshot.modules.every(module => module.status === 'legacy-bridge'), 'Bestandsmodule müssen als legacy-bridge markiert sein'); +assert(snapshot.features.some(flag => flag.name === 'platform.module-kernel' && flag.value), 'Kernel-Feature-Flag fehlt'); +assert(snapshot.policies.some(policy => policy.id === 'platform.security-admin'), 'Security-Admin-Policy fehlt'); +const health = await kernel.health(); +assert(health.ok, 'Kernel-Healthcheck ist nicht erfolgreich'); +await kernel.stop(); +assert(kernel.snapshot().lifecycle.state === 'stopped', 'Kernel stoppt nicht sauber'); + +const registry = new ModuleRegistry(); +registry.register({ manifest: { + schemaVersion: 1, id: 'vendoo.test-a', name: 'A', version: '1.0.0', type: 'feature', status: 'native', + requires: ['vendoo.test-b'], permissions: [], provides: ['test.a'], consumes: [], owns: ['test.a'], +} }); +registry.register({ manifest: { + schemaVersion: 1, id: 'vendoo.test-b', name: 'B', version: '1.0.0', type: 'feature', status: 'native', + requires: ['vendoo.test-a'], permissions: [], provides: ['test.b'], consumes: [], owns: ['test.b'], +} }); +await expectThrow(() => registry.startAll({}), 'MODULE_DEPENDENCY_CYCLE', 'Zyklische Modulabhängigkeit wurde nicht blockiert'); + +const ownershipRegistry = new ModuleRegistry(); +ownershipRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.owner-a', name: 'Owner A', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['owner.a'], consumes: [], owns: ['shared.resource'] } }); +await expectThrow(() => ownershipRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.owner-b', name: 'Owner B', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['owner.b'], consumes: [], owns: ['shared.resource'] } }), 'MODULE_OWNERSHIP_CONFLICT', 'Doppelte Modul-Ownership wurde nicht blockiert'); + +const capabilityRegistry = new ModuleRegistry(); +capabilityRegistry.register({ manifest: { schemaVersion: 1, id: 'vendoo.consumer', name: 'Consumer', version: '1.0.0', type: 'feature', status: 'native', requires: [], permissions: [], provides: ['consumer.ready'], consumes: ['missing.capability'], owns: ['consumer.state'] } }); +await expectThrow(() => capabilityRegistry.startAll({}), 'MODULE_CAPABILITY_MISSING', 'Fehlende Capability wurde nicht blockiert'); + +const policies = new PolicyEngine(); +policies.register('test.admin', ({ user }) => user?.role === 'admin'); +const routes = new RouteRegistry({ policyEngine: policies }); +const fakeApp = { get(path, handler) { this.path = path; this.handler = handler; } }; +await expectThrow(() => routes.register(fakeApp, { id: 'test.no-policy', method: 'GET', path: '/x', owner: 'vendoo.test', handler() {} }), 'ROUTE_POLICY_REQUIRED', 'Route ohne Policy wurde akzeptiert'); +routes.register(fakeApp, { id: 'test.route.read', method: 'GET', path: '/x', owner: 'vendoo.test', policy: 'test.admin', csrf: false, handler(_req, res) { res.json({ ok: true }); } }); +let deniedStatus = null; +await fakeApp.handler({ user: { role: 'viewer' }, requestId: 'test' }, { headersSent: false, status(code) { deniedStatus = code; return this; }, json() {} }, () => {}); +assert(deniedStatus === 403, `Deny-by-default-Route gab ${deniedStatus} statt 403 zurück`); + +const events = new EventBus(); +let eventCalls = 0; +events.on('test.created', () => { eventCalls += 1; }, { owner: 'vendoo.test' }); +await events.emit('test.created'); +assert(eventCalls === 1, 'Event Bus liefert Event nicht aus'); +assert(events.removeOwner('vendoo.test') === 1, 'Event-Owner-Cleanup funktioniert nicht'); + +const validTheme = validateThemeDefinition({ id: 'markus-theme', name: 'Markus Theme', values: { 'color.brand': '#123456', 'radius.md': '14px' } }); +assert(validTheme.values['color.brand'] === '#123456', 'Gültiges Theme wurde nicht akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: { 'shadow.float': 'url(javascript:alert(1))' } }), 'THEME_TOKEN_FORBIDDEN', 'Nicht anpassbarer Token wurde akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: { 'color.brand': 'url(javascript:alert(1))' } }), 'THEME_VALUE_INVALID', 'Unsicherer Farbwert wurde akzeptiert'); +await expectThrow(() => validateThemeDefinition({ id: 'unsafe-rgb', name: 'Unsafe RGB', values: { 'color.brand': 'rgb(999,0,0)' } }), 'THEME_VALUE_INVALID', 'Farbkanal außerhalb 0-255 wurde akzeptiert'); + +const tokenCssBefore = readFileSync(join(root, 'public', 'design-system', 'tokens.css'), 'utf8'); +const generated = spawnSync(process.execPath, [join(root, 'tools', 'generate-design-tokens.mjs')], { cwd: root, encoding: 'utf8' }); +assert(generated.status === 0, `Token-Generator fehlgeschlagen: ${generated.stderr || generated.stdout}`); +const tokenCssAfter = readFileSync(join(root, 'public', 'design-system', 'tokens.css'), 'utf8'); +assert(tokenCssBefore === tokenCssAfter, 'Token-Generator ist nicht deterministisch'); +assert(tokenCssAfter.includes('[data-theme="contrast"]'), 'Kontrast-Theme fehlt'); + +const style = readFileSync(join(root, 'public', 'style.css'), 'utf8'); +assert(style.startsWith("@import url('./design-system/tokens.css?v=1.35.0');"), 'style.css bindet Tokenvertrag nicht zuerst ein'); +assert(!style.includes('Vendoo UI Foundation 2026'), 'Alte eingebettete Tokenquelle ist noch vorhanden'); +const frontendRegistry = readFileSync(join(root, 'public', 'core', 'frontend-module-registry.js'), 'utf8'); +const safeDom = readFileSync(join(root, 'public', 'core', 'safe-dom.js'), 'utf8'); +const apiClient = readFileSync(join(root, 'public', 'core', 'api-client.js'), 'utf8'); +assert(frontendRegistry.includes('AbortController') && frontendRegistry.includes('async unmount()'), 'Frontend-Modul-Lifecycle fehlt'); +assert(safeDom.includes('textContent') && !safeDom.includes('innerHTML'), 'Safe-DOM-Vertrag ist unsicher'); +assert(apiClient.includes("credentials: 'same-origin'") && apiClient.includes('X-CSRF-Token'), 'API-Client-Sicherheitsvertrag fehlt'); + +const html = readFileSync(join(root, 'public', 'index.html'), 'utf8'); +assert(html.includes('design-system/theme-runtime.js?v=1.35.0'), 'Theme Runtime ist nicht eingebunden'); +const server = readFileSync(join(root, 'server.mjs'), 'utf8'); +for (const marker of ['createPlatformKernel', "id: 'platform.status.read'", "policy: 'platform.security-admin'", 'await platformKernel.stop()']) { + assert(server.includes(marker), `Serverintegration fehlt: ${marker}`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-architecture-')); +try { + writeFileSync(join(temp, 'proof.txt'), 'ok'); + assert(readFileSync(join(temp, 'proof.txt'), 'utf8') === 'ok', 'Temporäre Testumgebung ist nicht schreibbar'); +} finally { rmSync(temp, { recursive: true, force: true }); } + +if (failures.length) { + console.error(`Platform-Architecture-Gate 1.35.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(failure => console.error(`- ${failure}`)); + process.exit(1); +} +console.log(`Platform-Architecture-Gate 1.35.0 erfolgreich: ${snapshot.modules.length} Module, ${snapshot.features.length} Feature Flags, ${snapshot.policies.length} Policies, Tokenvertrag und Deny-by-default-Routen geprüft.`); diff --git a/tools/verify-platform-architecture-1.36.0.mjs b/tools/verify-platform-architecture-1.36.0.mjs new file mode 100644 index 0000000..7362592 --- /dev/null +++ b/tools/verify-platform-architecture-1.36.0.mjs @@ -0,0 +1,46 @@ +import { readFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { ModuleRegistry } from '../app/kernel/module-registry.mjs'; +import { PolicyEngine } from '../app/kernel/policy-engine.mjs'; +import { RouteRegistry } from '../app/kernel/route-registry.mjs'; +import { EventBus } from '../app/kernel/event-bus.mjs'; +import { loadBuiltInModules } from '../app/modules/catalog.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = async (fn, code, message) => { try { await fn(); failures.push(message); } catch (error) { if (error.code !== code) failures.push(`${message} (${error.code || error.message})`); } }; +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +assert(pkg.version === '1.36.0', `package.json ist ${pkg.version}`); + +const modules = loadBuiltInModules(); +assert(modules.length === 12, `Erwartet 12 Module, erhalten ${modules.length}`); +const themeModule = modules.find(item => item.manifest.id === 'vendoo.themes'); +assert(themeModule?.manifest.status === 'native', 'Theme-Modul ist nicht nativ'); +assert(modules.filter(item => item.manifest.status === 'legacy-bridge').length === 11, 'Es müssen 11 Legacy-Bridges verbleiben'); +const registry = new ModuleRegistry(); +for (const module of modules) registry.register(module); +assert(registry.snapshot().length === 12, 'Modulregistrierung unvollständig'); + +const cycle = new ModuleRegistry(); +cycle.register({ manifest: { schemaVersion:1,id:'vendoo.a',name:'A',version:'1.0.0',type:'feature',status:'native',requires:['vendoo.b'],permissions:[],provides:['a.ready'],consumes:[],owns:['a.state'] } }); +cycle.register({ manifest: { schemaVersion:1,id:'vendoo.b',name:'B',version:'1.0.0',type:'feature',status:'native',requires:['vendoo.a'],permissions:[],provides:['b.ready'],consumes:[],owns:['b.state'] } }); +await expectThrow(() => cycle.startAll({}), 'MODULE_DEPENDENCY_CYCLE', 'Zyklus wurde nicht blockiert'); + +const policies = new PolicyEngine(); +policies.register('themes.use', ({ user }) => Boolean(user)); +const routes = new RouteRegistry({ policyEngine: policies }); +const fake = { get(_path, handler) { this.handler = handler; } }; +await expectThrow(() => routes.register(fake, { id:'themes.no-policy',method:'GET',path:'/x',owner:'vendoo.themes',handler(){} }), 'ROUTE_POLICY_REQUIRED', 'Route ohne Policy wurde akzeptiert'); +routes.register(fake, { id:'themes.read',method:'GET',path:'/x',owner:'vendoo.themes',policy:'themes.use',csrf:false,handler(_req,res){res.json({ok:true});} }); +let status = 0; await fake.handler({ user:null, requestId:'test' }, { headersSent:false,status(code){status=code;return this;},json(){} },()=>{}); +assert(status === 403, `Deny-by-default ergab ${status}`); + +const events = new EventBus(); let count = 0; events.on('themes.changed', () => count++, { owner:'vendoo.themes' }); await events.emit('themes.changed'); +assert(count === 1 && events.removeOwner('vendoo.themes') === 1, 'Event-Bus Owner-Cleanup fehlerhaft'); +const kernelSource = readFileSync(join(root, 'app/kernel/platform-kernel.mjs'), 'utf8'); +for (const marker of ["policies.register('themes.use'", "policies.register('themes.manage'", "features.define('themes.manager'", "features.define('themes.accessibility-gate'"]) assert(kernelSource.includes(marker), `Kernelvertrag fehlt: ${marker}`); + +if (failures.length) { console.error(`Platform-Architecture-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); failures.forEach(item => console.error(`- ${item}`)); process.exit(1); } +console.log('Platform-Architecture-Gate 1.36.0 erfolgreich: 12 Module, 1 natives Theme-Modul, 11 Legacy-Bridges, Policies, Routenverträge und Event-Grenzen geprüft.'); diff --git a/tools/verify-release-1.24.0.mjs b/tools/verify-release-1.24.0.mjs new file mode 100644 index 0000000..d90af53 --- /dev/null +++ b/tools/verify-release-1.24.0.mjs @@ -0,0 +1,107 @@ +import { readFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import sharp from 'sharp'; +import { renderImageVersion, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const jobs = read('lib/ai-flux-prompt-jobs.mjs'); +const editor = read('lib/image-editor.mjs'); +const setup = read('setup.ps1'); +const packageJson = JSON.parse(read('package.json')); + +assert(packageJson.version === '1.24.0', 'Paketversion ist nicht 1.24.0.'); +assert(index.includes('name="vendoo-build" content="1.24.0"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.24.0'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.24.0'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.24.0'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.24.0'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(index.includes('data-tab="media-library"'), 'Galerie-Navigation fehlt.'); +assert(index.includes('id="media-library"'), 'Galerie-Seite fehlt.'); +assert(index.includes('id="media-grid"'), 'Galerie-Raster fehlt.'); +assert(index.includes('id="media-lightbox"'), 'Galerie-Lightbox fehlt.'); +assert(app.includes('function setupMediaLibrary()'), 'Galerie-Interaktionen fehlen.'); +assert(server.includes("app.get('/api/media-library'"), 'Galerie-API fehlt.'); +assert(server.includes("app.delete('/api/media-library/items'"), 'Galerie-Lösch-API fehlt.'); +assert(db.includes('export function listMediaLibraryItems'), 'Galerie-Datenaggregation fehlt.'); +assert(db.includes('getListingReferencedMediaPaths'), 'Listing-Bildschutz fehlt.'); + +assert(index.includes('id="pe-background-sensitivity"'), 'Regler für Hintergrundentfernung fehlt.'); +assert(index.includes('Niedriger schützt feine Kanten'), 'Erklärung der Hintergrundintensität fehlt.'); +assert(app.includes('background_sensitivity'), 'Vorschau übernimmt Hintergrundintensität nicht.'); +assert(editor.includes('solidThreshold = 24 + level * 2.1'), 'Serverseitige Intensitätslogik fehlt.'); + +assert(index.includes('id="flux-job-view-archive"'), 'FLUX-Archivumschalter fehlt.'); +assert(index.includes('id="flux-job-delete-all-archive"'), 'Komplettes Archivlöschen fehlt.'); +assert(index.includes('id="flux-job-page-size"'), 'Job-Center-Seitengröße fehlt.'); +assert(index.includes('id="flux-job-pagination"'), 'Job-Center-Paginierung fehlt.'); +assert(jobs.includes('archiveFluxPromptJobs'), 'FLUX-Archivfunktion fehlt.'); +assert(jobs.includes('restoreFluxPromptJobs'), 'FLUX-Wiederherstellung fehlt.'); +assert(jobs.includes('deleteArchivedFluxPromptJobs'), 'FLUX-Archivlöschung fehlt.'); +assert(jobs.includes("status NOT IN ('queued','running','cancelling')"), 'Aktive Aufträge sind nicht gegen Archivierung geschützt.'); +assert(db.includes('ALTER TABLE flux_prompt_jobs ADD COLUMN archived_at TEXT'), 'Additive Archivmigration fehlt.'); +assert(server.includes("app.post('/api/flux-prompt/jobs/archive'"), 'Archiv-API fehlt.'); +assert(server.includes("app.delete('/api/flux-prompt/jobs/archive'"), 'Archiv-Lösch-API fehlt.'); + +assert(style.includes('Real responsive listing cards'), 'Responsive Listing-Kartenregeln fehlen.'); +assert(style.includes('@media (max-width: 860px)'), 'Tablet-/Mobil-Breakpoint fehlt.'); +assert(style.includes('.history-table { min-width:0 !important;'), 'Listing-Tabelle bleibt auf Mobilbreite fixiert.'); +assert(style.includes('.media-grid.is-list'), 'Galerie-Listenansicht ist nicht gestylt.'); +assert(style.includes('.media-lightbox-dialog'), 'Galerie-Lightbox ist nicht gestylt.'); + +const extensionFiles = ['extension/content.js', 'extensions/content.js', 'extensions/chrome/content.js', 'extensions/edge/content.js', 'extensions/firefox/content.js']; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Vinted-Titelfeldschutz fehlt.`); + assert(content.includes('if (!trigger || isLikelyTitleField(trigger)) return false;'), `${file}: Auswahlfelder schützen den Titel nicht.`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-124-bg-')); +try { + const uploadRoot = join(temp, 'uploads'); + mkdirSync(join(uploadRoot, 'verification'), { recursive: true }); + const source = join(uploadRoot, 'verification', 'source.png'); + const image = Buffer.from(''); + await sharp(image).png().toFile(source); + + const render = async sensitivity => renderImageVersion({ + uploadRoot, + sourcePath: 'verification/source.png', + rootPath: 'verification/source.png', + context: 'verification', + operations: normalizeEditorOperations({ remove_background: true, background_sensitivity: sensitivity, format: 'png' }), + }); + const low = await render(5); + const high = await render(90); + const countTransparent = async filename => { + const raw = await sharp(join(uploadRoot, filename)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let count = 0; + for (let i = 3; i < raw.data.length; i += raw.info.channels) if (raw.data[i] < 32) count += 1; + return count; + }; + const lowTransparent = await countTransparent(low.filename); + const highTransparent = await countTransparent(high.filename); + assert(highTransparent > lowTransparent, `Höhere Intensität entfernt nicht mehr Hintergrund (${lowTransparent} vs. ${highTransparent}).`); + console.log(JSON.stringify({ + ok: true, + release: '1.24.0', + responsive_listing_cards: true, + flux_archive: true, + flux_pagination: true, + media_library: true, + listing_media_delete_protection: true, + background_sensitivity: { low_transparent_pixels: lowTransparent, high_transparent_pixels: highTransparent }, + vinted_title_guard_preserved: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-release-1.25.0.mjs b/tools/verify-release-1.25.0.mjs new file mode 100644 index 0000000..7fa0d90 --- /dev/null +++ b/tools/verify-release-1.25.0.mjs @@ -0,0 +1,105 @@ +import { readFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import sharp from 'sharp'; +import { renderImageVersion, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const jobs = read('lib/ai-flux-prompt-jobs.mjs'); +const router = read('lib/ai-router.mjs'); +const setup = read('setup.ps1'); +const packageJson = JSON.parse(read('package.json')); + +assert(packageJson.version === '1.25.0', 'Paketversion ist nicht 1.25.0.'); +assert(index.includes('name="vendoo-build" content="1.25.0"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.0'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.0'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.0'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.0'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('container-name:listing-editor'), 'Inhaltsbreitenabhängiger Listing-Editor fehlt.'); +assert(style.includes('@container listing-editor (max-width: 760px)'), 'Listing-Editor-Container-Breakpoint fehlt.'); +assert(style.includes('.detail-description-pane .ql-toolbar.ql-snow'), 'Responsive WYSIWYG-Toolbar fehlt.'); +assert(style.includes('Broad width hardening for all primary workspaces'), 'Globale Breitenhärtung fehlt.'); + +assert(index.includes('id="smart-copy-modal"'), 'Smart-Copy-Modal fehlt.'); +assert(app.includes('function openSmartCopyModal'), 'Smart-Copy-Modalsteuerung fehlt.'); +assert(app.includes('renderSmartCopyLauncher'), 'Smart-Copy-Einstieg fehlt.'); +assert(!style.includes('.vd-button.is-loading { animation: vd-publish-spin'), 'Gesamter Publish-Button rotiert weiterhin.'); +assert(app.includes("button.textContent = index === 0 ? 'Wird geladen …' : '…'"), 'Ruhiger Publish-Ladestatus fehlt.'); + +assert(index.includes('id="flux-prompt-lab"'), 'FLUX Prompt Lab fehlt.'); +assert(index.includes('id="flux-prompt-lab-ai"'), 'AI-Prompt-Aktion fehlt.'); +assert(server.includes("app.post('/api/flux-prompt/improve'"), 'Prompt-Verbesserungs-API fehlt.'); +assert(router.includes('export async function generateText'), 'Provider-Routing für Text fehlt.'); +for (const file of ['lib/ai-claude.mjs','lib/ai-openai.mjs','lib/ai-openrouter.mjs','lib/ai-local.mjs']) { + assert(read(file).includes('export async function generateText'), `${file}: Textgenerierung fehlt.`); +} + +assert(index.includes('id="flux-history-page-size"'), 'FLUX-Bildpagination fehlt.'); +assert(index.includes('id="flux-history-pagination"') || index.includes('class="flux-history-pagination"'), 'FLUX-Bild-Paginierungsleiste fehlt.'); +assert(db.includes('export function paginateFluxImages'), 'Serverseitige FLUX-Bildpagination fehlt.'); +assert(server.includes('paginateFluxImages'), 'FLUX-Bildpagination ist nicht angebunden.'); +assert(jobs.includes('Math.min(15, Number(pageSize)'), 'Job-Center ist nicht auf maximal 15 begrenzt.'); +assert(index.includes(''), 'Job-Seitengrößen 5/10/15 fehlen.'); + +assert(style.includes('.media-card-favorite-toggle'), 'Galerie-Favoriten-Overlay fehlt.'); +assert(style.includes('.media-card-select input:checked + span::after') && app.includes(''), 'Galerie-Auswahlfeld ist nicht sauber gestaltet.'); +assert(style.includes('.media-card-actions { display:grid'), 'Galerie-Aktionsraster fehlt.'); +assert(app.includes('class="media-card-preview"'), 'Galerie-Vorschauaktion fehlt.'); + +const extensionFiles = ['extension/content.js', 'extensions/content.js', 'extensions/chrome/content.js', 'extensions/edge/content.js', 'extensions/firefox/content.js']; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Vinted-Titelfeldschutz fehlt.`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-125-bg-')); +try { + const uploadRoot = join(temp, 'uploads'); + mkdirSync(join(uploadRoot, 'verification'), { recursive: true }); + const source = join(uploadRoot, 'verification', 'source.png'); + const image = Buffer.from(''); + await sharp(image).png().toFile(source); + const render = async sensitivity => renderImageVersion({ + uploadRoot, + sourcePath: 'verification/source.png', + rootPath: 'verification/source.png', + context: 'verification', + operations: normalizeEditorOperations({ remove_background: true, background_sensitivity: sensitivity, format: 'png' }), + }); + const low = await render(5); + const high = await render(90); + const countTransparent = async filename => { + const raw = await sharp(join(uploadRoot, filename)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let count = 0; + for (let i = 3; i < raw.data.length; i += raw.info.channels) if (raw.data[i] < 32) count += 1; + return count; + }; + const lowTransparent = await countTransparent(low.filename); + const highTransparent = await countTransparent(high.filename); + assert(highTransparent > lowTransparent, 'Hintergrundintensität reagiert nicht.'); + console.log(JSON.stringify({ + ok: true, + release: '1.25.0', + listing_editor_container_responsive: true, + gallery_card_ux: true, + smart_copy_modal: true, + publish_refresh_without_button_spin: true, + flux_history_pagination: [5,10,15], + flux_job_pagination: [5,10,15], + flux_prompt_lab_providers: ['claude','openai','openrouter','local'], + background_sensitivity: { low_transparent_pixels: lowTransparent, high_transparent_pixels: highTransparent }, + vinted_title_guard_preserved: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-release-1.25.1-static.mjs b/tools/verify-release-1.25.1-static.mjs new file mode 100644 index 0000000..26afdfa --- /dev/null +++ b/tools/verify-release-1.25.1-static.mjs @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const read = file => readFileSync(resolve(root, file), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const pkg = JSON.parse(read('package.json')); +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const setup = read('setup.ps1'); + +assert(pkg.version === '1.25.1', 'Paketversion ist nicht 1.25.1.'); +assert(index.includes('name="vendoo-build" content="1.25.1"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.1'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.1'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.1'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.1'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('grid-template-columns:repeat(auto-fit'), 'Automatisch fließendes Galerie-Raster fehlt.'); +assert(style.includes('container-name:media-card'), 'Galeriekarten sind keine Container.'); +assert(style.includes('@container media-card (max-width: 235px)'), 'Schmale Kartendarstellung fehlt.'); +assert(style.includes('@container media-card (min-width: 340px)'), 'Breite Kartendarstellung fehlt.'); +assert(app.includes('class="media-card-open"'), 'Klickbare Bildfläche fehlt.'); +assert(app.includes("mediaActionIcon('open')"), 'Professioneller Bildöffnen-Indikator fehlt.'); +assert(!app.includes('class="media-card-preview"'), 'Alter großer Öffnen-/Vorschau-Button ist noch vorhanden.'); +assert(app.includes('class="media-action"'), 'Adaptives Aktionsdock fehlt.'); +assert(app.includes("mediaActionIcon('edit')") && app.includes("mediaActionIcon('delete')"), 'Galerieaktionssymbole fehlen.'); +assert(index.match(/data-media-pagination/g)?.length === 2, 'Pagination muss oben und unten vorhanden sein.'); +assert(index.match(/data-media-page-jump/g)?.length === 2, 'Direkte Seitenauswahl muss oben und unten vorhanden sein.'); +assert(app.includes("root.querySelectorAll('[data-media-page-action]')"), 'Paginierungsbuttons sind nicht angebunden.'); +assert(app.includes("root.querySelectorAll('[data-media-page-jump]')"), 'Direkter Seitensprung ist nicht angebunden.'); +assert(style.includes('.media-pagination-top') && style.includes('.media-pagination-bottom'), 'Pagination ist nicht oben und unten gestaltet.'); +assert(style.includes('.media-card.is-selected .media-card-image'), 'Sichtbarer Auswahlzustand fehlt.'); +assert(style.includes('@media (hover:none)'), 'Touch-Geräte werden nicht berücksichtigt.'); + +console.log(JSON.stringify({ + ok: true, + release: '1.25.1', + adaptive_grid: true, + card_container_queries: true, + full_image_click_target: true, + oversized_open_button_removed: true, + adaptive_selection_and_favorite_overlays: true, + adaptive_action_dock: true, + pagination_top_and_bottom: true, + direct_page_jump: true, + touch_behavior: true, +}, null, 2)); diff --git a/tools/verify-release-1.25.1.mjs b/tools/verify-release-1.25.1.mjs new file mode 100644 index 0000000..01caacc --- /dev/null +++ b/tools/verify-release-1.25.1.mjs @@ -0,0 +1,115 @@ +import { readFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import sharp from 'sharp'; +import { renderImageVersion, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const read = path => readFileSync(resolve(root, path), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const index = read('public/index.html'); +const app = read('public/app.js'); +const style = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const jobs = read('lib/ai-flux-prompt-jobs.mjs'); +const router = read('lib/ai-router.mjs'); +const setup = read('setup.ps1'); +const packageJson = JSON.parse(read('package.json')); + +assert(packageJson.version === '1.25.1', 'Paketversion ist nicht 1.25.1.'); +assert(index.includes('name="vendoo-build" content="1.25.1"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.25.1'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('app.js?v=1.25.1'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.25.1'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.25.1'), 'Windows-Start-Cache-Buster fehlt.'); + +assert(style.includes('container-name:listing-editor'), 'Inhaltsbreitenabhängiger Listing-Editor fehlt.'); +assert(style.includes('@container listing-editor (max-width: 760px)'), 'Listing-Editor-Container-Breakpoint fehlt.'); +assert(style.includes('.detail-description-pane .ql-toolbar.ql-snow'), 'Responsive WYSIWYG-Toolbar fehlt.'); +assert(style.includes('Broad width hardening for all primary workspaces'), 'Globale Breitenhärtung fehlt.'); + +assert(index.includes('id="smart-copy-modal"'), 'Smart-Copy-Modal fehlt.'); +assert(app.includes('function openSmartCopyModal'), 'Smart-Copy-Modalsteuerung fehlt.'); +assert(app.includes('renderSmartCopyLauncher'), 'Smart-Copy-Einstieg fehlt.'); +assert(!style.includes('.vd-button.is-loading { animation: vd-publish-spin'), 'Gesamter Publish-Button rotiert weiterhin.'); +assert(app.includes("button.textContent = index === 0 ? 'Wird geladen …' : '…'"), 'Ruhiger Publish-Ladestatus fehlt.'); + +assert(index.includes('id="flux-prompt-lab"'), 'FLUX Prompt Lab fehlt.'); +assert(index.includes('id="flux-prompt-lab-ai"'), 'AI-Prompt-Aktion fehlt.'); +assert(server.includes("app.post('/api/flux-prompt/improve'"), 'Prompt-Verbesserungs-API fehlt.'); +assert(router.includes('export async function generateText'), 'Provider-Routing für Text fehlt.'); +for (const file of ['lib/ai-claude.mjs','lib/ai-openai.mjs','lib/ai-openrouter.mjs','lib/ai-local.mjs']) { + assert(read(file).includes('export async function generateText'), `${file}: Textgenerierung fehlt.`); +} + +assert(index.includes('id="flux-history-page-size"'), 'FLUX-Bildpagination fehlt.'); +assert(index.includes('id="flux-history-pagination"') || index.includes('class="flux-history-pagination"'), 'FLUX-Bild-Paginierungsleiste fehlt.'); +assert(db.includes('export function paginateFluxImages'), 'Serverseitige FLUX-Bildpagination fehlt.'); +assert(server.includes('paginateFluxImages'), 'FLUX-Bildpagination ist nicht angebunden.'); +assert(jobs.includes('Math.min(15, Number(pageSize)'), 'Job-Center ist nicht auf maximal 15 begrenzt.'); +assert(index.includes(''), 'Job-Seitengrößen 5/10/15 fehlen.'); + +assert(style.includes('.media-card-favorite-toggle'), 'Galerie-Favoriten-Overlay fehlt.'); +assert(style.includes('.media-card-select input:checked + span::after') && app.includes(''), 'Galerie-Auswahlfeld ist nicht sauber gestaltet.'); +assert(style.includes('grid-template-columns:repeat(auto-fit'), 'Galerie verwendet kein automatisch fließendes Raster.'); +assert(style.includes('container-name:media-card') && style.includes('@container media-card'), 'Galeriekarten skalieren nicht anhand ihrer eigenen Breite.'); +assert(app.includes('class="media-card-open"') && app.includes('data-media-preview'), 'Klickbare Bildfläche fehlt.'); +assert(!app.includes('class="media-card-preview"'), 'Der alte große Vorschau-/Öffnen-Button ist noch vorhanden.'); +assert(app.includes('class="media-action"'), 'Adaptives Galerie-Aktionsdock fehlt.'); +assert(index.match(/data-media-pagination/g)?.length === 2, 'Pagination muss oberhalb und unterhalb der Galerie vorhanden sein.'); +assert(index.match(/data-media-page-jump/g)?.length === 2, 'Direkte Seitenauswahl fehlt oben oder unten.'); +assert(app.includes("root.querySelectorAll('[data-media-page-jump]')"), 'Seitensprung ist nicht angebunden.'); +assert(style.includes('.media-pagination-top') && style.includes('.media-pagination-bottom'), 'Obere/untere Pagination ist nicht gestaltet.'); + +const extensionFiles = ['extension/content.js', 'extensions/content.js', 'extensions/chrome/content.js', 'extensions/edge/content.js', 'extensions/firefox/content.js']; +for (const file of extensionFiles) { + const content = read(file); + assert(content.includes('function isLikelyTitleField(el)'), `${file}: Vinted-Titelfeldschutz fehlt.`); +} + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-125-bg-')); +try { + const uploadRoot = join(temp, 'uploads'); + mkdirSync(join(uploadRoot, 'verification'), { recursive: true }); + const source = join(uploadRoot, 'verification', 'source.png'); + const image = Buffer.from(''); + await sharp(image).png().toFile(source); + const render = async sensitivity => renderImageVersion({ + uploadRoot, + sourcePath: 'verification/source.png', + rootPath: 'verification/source.png', + context: 'verification', + operations: normalizeEditorOperations({ remove_background: true, background_sensitivity: sensitivity, format: 'png' }), + }); + const low = await render(5); + const high = await render(90); + const countTransparent = async filename => { + const raw = await sharp(join(uploadRoot, filename)).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let count = 0; + for (let i = 3; i < raw.data.length; i += raw.info.channels) if (raw.data[i] < 32) count += 1; + return count; + }; + const lowTransparent = await countTransparent(low.filename); + const highTransparent = await countTransparent(high.filename); + assert(highTransparent > lowTransparent, 'Hintergrundintensität reagiert nicht.'); + console.log(JSON.stringify({ + ok: true, + release: '1.25.1', + listing_editor_container_responsive: true, + gallery_card_ux: true, + adaptive_gallery_grid: true, + full_image_click_target: true, + pagination_top_and_bottom: true, + smart_copy_modal: true, + publish_refresh_without_button_spin: true, + flux_history_pagination: [5,10,15], + flux_job_pagination: [5,10,15], + flux_prompt_lab_providers: ['claude','openai','openrouter','local'], + background_sensitivity: { low_transparent_pixels: lowTransparent, high_transparent_pixels: highTransparent }, + vinted_title_guard_preserved: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-release-1.26.0-static.mjs b/tools/verify-release-1.26.0-static.mjs new file mode 100644 index 0000000..81b76d9 --- /dev/null +++ b/tools/verify-release-1.26.0-static.mjs @@ -0,0 +1,94 @@ +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const index = read('public/index.html'); +const app = read('public/app.js'); +const css = read('public/style.css'); +const server = read('server.mjs'); +const db = read('lib/db.mjs'); +const fees = read('lib/fees.mjs'); +const icons = read('public/vendoo-icons.js'); +const setup = read('setup.ps1'); + +assert(pkg.version === '1.26.0', 'Paketversion ist nicht 1.26.0.'); +assert(lock.version === '1.26.0' && lock.packages?.['']?.version === '1.26.0', 'Lockfile-Version ist nicht 1.26.0.'); +assert(index.includes('name="vendoo-build" content="1.26.0"'), 'Build-Metadatum fehlt.'); +assert(index.includes('style.css?v=1.26.0'), 'CSS-Cache-Buster fehlt.'); +assert(index.includes('vendoo-icons.js?v=1.26.0'), 'Iconbibliothek ist nicht eingebunden.'); +assert(index.includes('app.js?v=1.26.0'), 'JS-Cache-Buster fehlt.'); +assert(app.includes("VENDOO_UI_BUILD = '1.26.0'"), 'UI-Buildkennung fehlt.'); +assert(setup.includes('/?build=1.26.0'), 'Windows-Start-Cache-Buster fehlt.'); + +const dashboardPos = index.indexOf('data-tab="dashboard"'); +const newArticlePos = index.indexOf('data-tab="generator"'); +const todayPos = index.indexOf('data-tab="today"'); +assert(dashboardPos >= 0 && newArticlePos > dashboardPos && newArticlePos < todayPos, 'Neuer Artikel steht nicht direkt unter Dashboard.'); +assert(index.includes('Neuer Artikel'), 'Navigationsbezeichnung Neuer Artikel fehlt.'); +assert(index.includes('Artikel'), 'Navigationsbezeichnung Artikel fehlt.'); +assert(index.includes('Artikel generieren'), 'Artikel-generieren-Bezeichnung fehlt.'); +assert(!index.includes('>Neues Listing<'), 'Alte UI-Bezeichnung Neues Listing ist noch vorhanden.'); +assert(!index.includes('>Listing generieren<'), 'Alte UI-Bezeichnung Listing generieren ist noch vorhanden.'); + +assert(index.includes('id="extensions"'), 'Extension Center fehlt.'); +assert(index.includes('id="extensions-prepare-active"'), 'Ein-Klick-Vorbereitung für aktiven Browser fehlt.'); +assert(app.includes('detectActiveBrowser'), 'Browsererkennung fehlt.'); +assert(server.includes("app.get('/api/extensions/info'"), 'Extension-Info-Route fehlt.'); +assert(server.includes("app.post('/api/extensions/prepare'"), 'Extension-Prepare-Route fehlt.'); +assert(server.includes("app.get('/api/extensions/download/:browser'"), 'Extension-Download-Route fehlt.'); +for (const browser of ['chrome', 'edge', 'firefox', 'safari']) { + assert(existsSync(join(root, 'extensions', browser)), `Extension-Ordner ${browser} fehlt.`); + assert(existsSync(join(root, 'extensions', 'packages', `vendoo-link-${browser}-1.7.0.zip`)), `Extension-Paket ${browser} fehlt.`); +} +assert(existsSync(join(root, 'extensions', 'safari', 'web-extension', 'manifest.json')), 'Safari-WebExtension-Manifest fehlt.'); +assert(existsSync(join(root, 'extensions', 'safari', 'build-safari-extension.command')), 'Safari-Konvertierungsskript fehlt.'); + +for (const id of ['media-zoom-in','media-zoom-out','media-zoom-fit','media-zoom-original','media-zoom-range','media-lightbox-stage']) { + assert(index.includes(`id="${id}"`), `Zoom-Steuerung ${id} fehlt.`); +} +assert(app.includes('function fitMediaLightbox'), 'Fit-Zoom fehlt.'); +assert(app.includes("zoomStage?.addEventListener('wheel'"), 'Mausrad-Zoom fehlt.'); +assert(app.includes("zoomStage?.addEventListener('pointerdown'"), 'Zoom-Panning fehlt.'); +assert(icons.includes("'heart-filled'"), 'Favoriten-Icon fehlt.'); +assert(icons.includes('check:'), 'Auswahl-Icon fehlt.'); + +assert(db.includes('function parseJsonArray(value)'), 'Robuster JSON-Array-Parser fehlt.'); +assert(db.includes("String(value).split(',')"), 'Fallback für ältere Artikeldaten fehlt.'); +assert(app.includes('const workspace = document.getElementById(\'publish-workspace\')'), 'Publish-Workspace-Container fehlt.'); +assert(!app.includes('scope.querySelector(\'[data-publish-direct]'), 'Alter Publish-Scope-Fehler ist noch vorhanden.'); +assert(app.includes('openSmartCopyModal(selectedPublishListing())'), 'Smart-Copy-Popup-Verknüpfung fehlt.'); +assert(app.includes('smart-copy-center-description'), 'Zentrale Smart-Copy-Datenansicht fehlt.'); + +assert(fees.includes("'ebay-de':") && fees.includes("fees: 0"), 'eBay-Privatgebühr ist nicht auf 0 gesetzt.'); +assert(fees.includes("vinted:") && fees.includes('Käuferschutz'), 'Vinted-Gebührenhinweis fehlt.'); +assert(fees.includes("'ebay-ka':") && fees.includes('normalen Privatverkauf'), 'Kleinanzeigen-Hinweis fehlt.'); +assert(fees.includes('gross * 0.065') && fees.includes('gross * 0.04 + 0.30'), 'Etsy-Kalkulation fehlt.'); +assert(index.includes('Stand 08.07.2026'), 'Gebührenstand fehlt.'); + +const ids = [...index.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; +assert(!duplicates.length, `Doppelte HTML-IDs: ${duplicates.join(', ')}`); +let depth = 0; +for (const char of css.replace(/\/\*[\s\S]*?\*\//g, '')) { + if (char === '{') depth++; + if (char === '}') depth--; + assert(depth >= 0, 'CSS enthält eine vorzeitig geschlossene Klammer.'); +} +assert(depth === 0, 'CSS-Klammern sind nicht ausgeglichen.'); + +const result = { + release: '1.26.0', + html_ids: ids.length, + extension_targets: 4, + checks: [ + 'version-and-cache-busting', 'article-navigation', 'publish-hardening', + 'smart-copy-modal', 'media-zoom', 'local-icon-library', 'fee-model', + 'extension-center', 'safari-source', 'unique-html-ids', 'balanced-css' + ] +}; +console.log(JSON.stringify(result, null, 2)); diff --git a/tools/verify-release-1.26.1-static.mjs b/tools/verify-release-1.26.1-static.mjs new file mode 100644 index 0000000..fcfcee9 --- /dev/null +++ b/tools/verify-release-1.26.1-static.mjs @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} + +const files = walk(root); +for (const file of files) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +if (pkg.version !== '1.26.1') failures.push(`package.json-Version ist ${pkg.version}`); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +summary.html_ids = [...html.matchAll(/\bid="([^"]+)"/g)].length; +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); + +for (const marker of ['content="1.26.1"', 'style.css?v=1.26.1', 'app.js?v=1.26.1']) { + if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +} +if (!app.startsWith("const VENDOO_UI_BUILD = '1.26.1';")) failures.push('UI-Build ist nicht 1.26.1'); +for (const marker of ["app.get('/healthz'", "app.get('/api/admin/diagnostics'", "db.pragma('quick_check'", 'probeComfyUi']) { + if (!server.includes(marker)) failures.push(`Server-Diagnose fehlt: ${marker}`); +} +for (const path of [ + 'extensions/chrome/manifest.json', 'extensions/edge/manifest.json', + 'extensions/firefox/manifest.json', 'extensions/safari/web-extension/manifest.json', + 'local-ai/workflows/vendoo_flux_schnell_api.json', +]) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); + +const forbidden = ['FASHN', 'virtual_tryon', 'LoadImage']; +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, + version: pkg.version, + ...summary, + health_endpoint: true, + admin_diagnostics: true, + request_timeout_and_retry: true, + workspace_resize_contracts: true, + extension_variants: 4, +}, null, 2)); diff --git a/tools/verify-release-1.27.0.mjs b/tools/verify-release-1.27.0.mjs new file mode 100644 index 0000000..82fb737 --- /dev/null +++ b/tools/verify-release-1.27.0.mjs @@ -0,0 +1,88 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} + +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const worker = readFileSync(resolve(root, 'lib/image-batch-jobs.mjs'), 'utf8'); +const editor = readFileSync(resolve(root, 'lib/image-editor.mjs'), 'utf8'); + +if (pkg.version !== '1.27.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.27.0"', 'style.css?v=1.27.0', 'app.js?v=1.27.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.27.0';")) failures.push('UI-Build ist nicht 1.27.0'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +if (!sections.has('image-factory')) failures.push('Bildproduktion-Seite fehlt'); +if (!html.includes('data-tab="image-factory"')) failures.push('Bildproduktion-Navigation fehlt'); + +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['.image-factory-layout', '.image-factory-source-grid', '.image-factory-profile-grid', '.image-factory-job-list', '@container (max-width: 680px)']) if (!css.includes(marker)) failures.push(`Bildproduktions-CSS fehlt: ${marker}`); + +for (const marker of [ + "app.get('/api/image-batch/profiles'", "app.post('/api/image-batch/jobs'", "app.post('/api/image-batch/jobs/:id/pause'", + "app.post('/api/image-batch/jobs/:id/resume'", "app.post('/api/image-batch/jobs/:id/cancel'", "app.post('/api/image-batch/jobs/:id/retry'", + "app.get('/api/image-batch/jobs/:id/archive'", 'startImageBatchJobLoop()', +]) if (!server.includes(marker)) failures.push(`Batch-API fehlt: ${marker}`); +for (const table of ['image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items']) if (!db.includes(`CREATE TABLE IF NOT EXISTS ${table}`)) failures.push(`Datenbanktabelle fehlt: ${table}`); +for (const profile of ['ebay-main','vinted-portrait','kleinanzeigen','etsy','instagram-square','instagram-portrait','webshop','original-optimized']) if (!db.includes(`'${profile}'`)) failures.push(`Standardprofil fehlt: ${profile}`); +for (const marker of ['createImageBatchJob', 'pauseImageBatchJob', 'resumeImageBatchJob', 'cancelImageBatchJob', 'retryImageBatchJob', 'retryImageBatchItem', 'getImageBatchOutputPaths']) if (!worker.includes(`function ${marker}`) && !worker.includes(`function ${marker}(`) && !worker.includes(`export function ${marker}`)) failures.push(`Worker-Funktion fehlt: ${marker}`); +for (const marker of ['outputDirectory = null', "RESIZE_FITS = new Set(['inside', 'contain', 'cover'])", "context: 'batch'"]) if (!(editor + worker).includes(marker)) failures.push(`Render-Erweiterung fehlt: ${marker}`); +for (const marker of ['setupImageFactory()', "if (tabId === 'image-factory') loadImageFactory()", 'submitImageFactoryJob', 'media-send-to-factory']) if (!app.includes(marker) && !html.includes(marker)) failures.push(`UI-Anbindung fehlt: ${marker}`); + +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); + +for (const path of ['lib/image-batch-jobs.mjs', 'tools/verify-slice32-image-render.mjs', 'local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, version: pkg.version, ...summary, + built_in_profiles: 8, persistent_batch_jobs: true, pause_resume_cancel_retry: true, + restart_recovery: true, gallery_integration: true, zip_export: true, +}, null, 2)); diff --git a/tools/verify-release-1.28.0.mjs b/tools/verify-release-1.28.0.mjs new file mode 100644 index 0000000..58dd428 --- /dev/null +++ b/tools/verify-release-1.28.0.mjs @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0 }; +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const quality = readFileSync(resolve(root, 'lib/quality-center.mjs'), 'utf8'); +if (pkg.version !== '1.28.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.28.0"', 'style.css?v=1.28.0', 'app.js?v=1.28.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.28.0';")) failures.push('UI-Build ist nicht 1.28.0'); +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['data-tab="quality-center"','id="quality-center"','id="quality-ai-modal"','id="quality-analyze-visible"']) if (!html.includes(marker)) failures.push(`Quality-Center-UI fehlt: ${marker}`); +for (const marker of ['.quality-workspace','.quality-score-ring','.quality-ai-compare','@container (max-width: 920px)']) if (!css.includes(marker)) failures.push(`Quality-Center-CSS fehlt: ${marker}`); +for (const marker of ['setupQualityCenter()', "if (tabId === 'quality-center') loadQualityCenter()", 'generateQualityAiSuggestion', 'applyQualityAiSuggestion']) if (!app.includes(marker)) failures.push(`Quality-Center-JavaScript fehlt: ${marker}`); +for (const marker of ["app.get('/api/quality-center'", "app.post('/api/quality-center/:id/analyze'", "app.post('/api/quality-center/:id/ai-suggestions'", "app.post('/api/quality-center/:id/apply-suggestions'"]) if (!server.includes(marker)) failures.push(`Quality-Center-API fehlt: ${marker}`); +if (!db.includes('CREATE TABLE IF NOT EXISTS quality_reports')) failures.push('quality_reports-Tabelle fehlt'); +for (const marker of ['analyzeListingQuality','listQualityCenter','getQualityHistory','compareQualityReports','findDuplicates','inspectImages']) if (!quality.includes(marker)) failures.push(`Quality-Modul-Funktion fehlt: ${marker}`); +for (const marker of ['missing_title','missing_description','missing_photos','missing_price','duplicate_sku','photo_low_resolution']) if (!quality.includes(marker)) failures.push(`Qualitätsregel fehlt: ${marker}`); +if (!server.includes('Die AI erstellt') && !html.includes('Die AI erstellt nur Vorschläge')) failures.push('AI-Bestätigungsregel fehlt'); +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +for (const path of ['lib/quality-center.mjs','docs/SLICE_33_ARTICLE_QUALITY_CENTER.md','local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); +if (failures.length) { console.error(JSON.stringify({ ok:false, failures, summary }, null, 2)); process.exit(1); } +console.log(JSON.stringify({ ok:true, version:pkg.version, ...summary, quality_center:true, persistent_reports:true, duplicate_detection:true, image_inspection:true, ai_confirmation:true }, null, 2)); diff --git a/tools/verify-release-1.29.0.mjs b/tools/verify-release-1.29.0.mjs new file mode 100644 index 0000000..e5d78be --- /dev/null +++ b/tools/verify-release-1.29.0.mjs @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0, extension_packages: 0 }; +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const publishing = readFileSync(resolve(root, 'lib/publishing-jobs.mjs'), 'utf8'); +const ebay = readFileSync(resolve(root, 'lib/ebay-api.mjs'), 'utf8'); +const etsy = readFileSync(resolve(root, 'lib/etsy-api.mjs'), 'utf8'); +if (pkg.version !== '1.29.0') failures.push(`package.json-Version ist ${pkg.version}`); +for (const marker of ['content="1.29.0"', 'style.css?v=1.29.0', 'app.js?v=1.29.0']) if (!html.includes(marker)) failures.push(`Build-Marker fehlt: ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.29.0';")) failures.push('UI-Build ist nicht 1.29.0'); +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); +for (const marker of ['data-publish-view="queue"','data-publish-view="errors"','id="publish-error-count"','Zur Publishing-Queue']) if (!html.includes(marker)) failures.push(`Publishing-Hardening-UI fehlt: ${marker}`); +for (const marker of ['loadPublishingQueueData','renderPublishingErrorsWorkspace','runPublishingJobAction','deleteSelectedPublishingJobs','publishingStatusLabel']) if (!app.includes(marker)) failures.push(`Publishing-Hardening-JavaScript fehlt: ${marker}`); +for (const marker of ["app.get('/api/publishing/jobs'", "app.post('/api/publishing/jobs'", "app.post('/api/publishing/jobs/:id/retry'", "app.post('/api/publishing/jobs/:id/action'", "app.get('/api/publishing/events'", "app.post('/api/extension/publishing/:id/complete'"]) if (!server.includes(marker)) failures.push(`Publishing-Hardening-API fehlt: ${marker}`); +for (const marker of ['CREATE TABLE IF NOT EXISTS publishing_jobs','CREATE TABLE IF NOT EXISTS publishing_events','ALTER TABLE publish_log ADD COLUMN']) if (!db.includes(marker)) failures.push(`Publishing-Datenbank fehlt: ${marker}`); +for (const marker of ['enqueuePublishingJob','DUPLICATE_PUBLISH','retry_wait','recoverPublishingJobs','claimExtensionJob','completeExtensionJob','idempotency_key']) if (!publishing.includes(marker)) failures.push(`Publishing-Modul-Vertrag fehlt: ${marker}`); +for (const marker of ['getOffer','updateOffer','withdrawOffer']) if (!ebay.includes(`function ${marker}`)) failures.push(`eBay-Hardening fehlt: ${marker}`); +if (!etsy.includes('function getEtsyListing')) failures.push('Etsy-Statusabgleich fehlt'); +for (const marker of ['5 / Seite','10 / Seite','15 / Seite','Publishing-Queue','Fehlerzentrale']) if (!app.includes(marker) && !html.includes(marker)) failures.push(`Queue-UX fehlt: ${marker}`); +for (const path of ['lib/publishing-jobs.mjs','docs/SLICE_34_PUBLISHING_HARDENING.md','local-ai/workflows/vendoo_flux_schnell_api.json']) if (!existsSync(resolve(root, path))) failures.push(`Pflichtdatei fehlt: ${path}`); +for (const browser of ['chrome','edge','firefox','safari']) { + const pkgPath = resolve(root, 'extensions/packages', `vendoo-link-${browser}-1.8.0.zip`); + if (!existsSync(pkgPath)) failures.push(`Extension-Paket fehlt: ${relative(root,pkgPath)}`); else summary.extension_packages += 1; +} +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); +if (failures.length) { console.error(JSON.stringify({ ok:false, failures, summary }, null, 2)); process.exit(1); } +console.log(JSON.stringify({ + ok:true, version:pkg.version, ...summary, + persistent_publishing_queue:true, + duplicate_publish_guard:true, + automatic_retry:true, + restart_recovery:true, + extension_completion:true, + ebay_sync_update_end:true, + etsy_sync:true, +}, null, 2)); diff --git a/tools/verify-release-1.30.0.mjs b/tools/verify-release-1.30.0.mjs new file mode 100644 index 0000000..150f263 --- /dev/null +++ b/tools/verify-release-1.30.0.mjs @@ -0,0 +1,152 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { resolve, extname, relative } from 'path'; +import { spawnSync } from 'child_process'; +import { createZip } from '../lib/zip.mjs'; +import { readZip, readZipEntries } from '../lib/archive.mjs'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const failures = []; +const summary = { js_files: 0, json_files: 0, html_ids: 0, css_blocks: 0, nav_targets: 0, extension_packages: 0, operations_markers: 0 }; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return ['node_modules', '.git', 'uploads', 'backups', 'operations'].includes(entry.name) ? [] : walk(full); + return [full]; + }); +} +function expect(source, marker, label = marker) { + if (!source.includes(marker)) failures.push(`Marker fehlt: ${label}`); else summary.operations_markers += 1; +} + +for (const file of walk(root)) { + const ext = extname(file).toLowerCase(); + if (['.js', '.mjs'].includes(ext)) { + summary.js_files += 1; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) failures.push(`${relative(root, file)}: ${result.stderr.trim()}`); + } + if (ext === '.json') { + summary.json_files += 1; + try { JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { failures.push(`${relative(root, file)}: ungültiges JSON (${error.message})`); } + } +} + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const lock = JSON.parse(readFileSync(resolve(root, 'package-lock.json'), 'utf8')); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const app = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); +const icons = readFileSync(resolve(root, 'public/vendoo-icons.js'), 'utf8'); +const server = readFileSync(resolve(root, 'server.mjs'), 'utf8'); +const db = readFileSync(resolve(root, 'lib/db.mjs'), 'utf8'); +const operations = readFileSync(resolve(root, 'lib/operations-center.mjs'), 'utf8'); +const archive = readFileSync(resolve(root, 'lib/archive.mjs'), 'utf8'); +const setup = readFileSync(resolve(root, 'setup.ps1')); + +if (pkg.version !== '1.30.0') failures.push(`package.json-Version ist ${pkg.version}`); +if (lock.version !== '1.30.0' || lock.packages?.['']?.version !== '1.30.0') failures.push('package-lock-Version ist nicht 1.30.0'); +for (const marker of ['content="1.30.0"', 'style.css?v=1.30.0', 'app.js?v=1.30.0']) expect(html, marker, `Build-Marker ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.30.0';")) failures.push('UI-Build ist nicht 1.30.0'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +summary.html_ids = ids.length; +const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${duplicateIds.join(', ')}`); +const sections = new Set([...html.matchAll(/ match[1])); +const navTargets = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +summary.nav_targets = navTargets.length; +for (const target of navTargets) if (!sections.has(target)) failures.push(`Navigation ohne Zielsektion: ${target}`); +summary.css_blocks = (css.match(/{/g) || []).length; +if (summary.css_blocks !== (css.match(/}/g) || []).length) failures.push('CSS-Klammern unausgeglichen'); + +for (const marker of [ + 'id="operations-center-title"', 'data-operations-tab="backup"', 'data-operations-tab="updates"', + 'data-operations-tab="extensions"', 'id="operations-backup-list"', 'id="operations-update-input"', + 'id="operations-extension-clients"', 'id="operations-pending-banner"', +]) expect(html, marker, `Operations-UI ${marker}`); +for (const marker of [ + 'loadOperationsCenter', 'renderOperationsBackups', 'renderOperationsExtensions', 'operationsUploadRequest', + '/api/operations/restore/stage', '/api/operations/updates/stage', '/api/extensions/diagnostics', +]) expect(app, marker, `Operations-JavaScript ${marker}`); +for (const marker of [ + "app.get('/api/operations/status'", "app.post('/api/operations/backups'", "app.post('/api/operations/restore/stage'", + "app.post('/api/operations/updates/stage'", "app.post('/api/extensions/heartbeat'", "app.get('/api/extensions/diagnostics'", +]) expect(server, marker, `Operations-API ${marker}`); +for (const marker of [ + 'CREATE TABLE IF NOT EXISTS operation_backups', 'CREATE TABLE IF NOT EXISTS operation_settings', + 'CREATE TABLE IF NOT EXISTS update_operations', 'CREATE TABLE IF NOT EXISTS system_migrations', + 'CREATE TABLE IF NOT EXISTS extension_clients', 'operations-center-1.30.0', +]) expect(db, marker, `Operations-Datenbank ${marker}`); +for (const marker of [ + 'createDatabaseSnapshot', 'verifyBackupBuffer', 'stageRestore', 'stageUpdate', 'startAutomaticBackupLoop', + 'recordExtensionHeartbeat', 'getExtensionDiagnostics', 'VENDOO_UPDATE_MANIFEST_URL', +]) expect(operations, marker, `Operations-Modul ${marker}`); +for (const marker of ['readZipEntries', 'extractZipEntry', 'Unsicherer ZIP-Pfad']) expect(archive, marker, `ZIP-Schutz ${marker}`); +for (const marker of ['Apply-VendooPendingOperation', 'Invoke-VendooRollback', "'11' { Apply-VendooPendingOperation }", "'12' { Invoke-VendooRollback }"]) expect(setup.toString('utf8'), marker, `Setup-Operation ${marker}`); +if (!(setup[0] === 0xef && setup[1] === 0xbb && setup[2] === 0xbf)) failures.push('setup.ps1 besitzt kein UTF-8-BOM'); + +for (const icon of ['database-backup','calendar-clock','scan-check','package-check','plug-zap','file-archive','radio-tower','shield-alert']) expect(icons, `'${icon}'`, `Icon ${icon}`); + +for (const browser of ['chrome', 'edge', 'firefox', 'safari']) { + const packagePath = resolve(root, 'extensions/packages', `vendoo-link-${browser}-1.9.0.zip`); + if (!existsSync(packagePath)) failures.push(`Extension-Paket fehlt: ${relative(root, packagePath)}`); + else { + summary.extension_packages += 1; + const test = spawnSync('unzip', ['-t', packagePath], { encoding: 'utf8' }); + if (test.status !== 0) failures.push(`Extension-ZIP fehlerhaft: ${browser}`); + } +} +for (const path of [ + 'extensions/chrome/manifest.json', 'extensions/edge/manifest.json', 'extensions/firefox/manifest.json', + 'extensions/safari/web-extension/manifest.json', +]) { + const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')); + if (manifest.version !== '1.9.0') failures.push(`${path} ist nicht Version 1.9.0`); +} +for (const path of ['extensions/content.js', 'extensions/popup.js']) { + const source = readFileSync(resolve(root, path), 'utf8'); + for (const marker of ['/api/extensions/heartbeat', "version: '1.9.0'", 'vendooDiagnosticClientId']) expect(source, marker, `${path}: ${marker}`); +} + +// ZIP parser roundtrip and traversal protection. +const roundtrip = createZip([ + { name: 'backup-manifest.json', data: Buffer.from('{"ok":true}') }, + { name: 'db/vendoo.db', data: Buffer.from('sqlite-test') }, +]); +const parsed = readZip(roundtrip); +if (parsed.files.get('db/vendoo.db')?.toString() !== 'sqlite-test') failures.push('ZIP-Roundtrip fehlgeschlagen'); +let traversalBlocked = false; +try { + const malicious = createZip([{ name: '../escape.txt', data: Buffer.from('x') }]); + readZipEntries(malicious); +} catch { traversalBlocked = true; } +if (!traversalBlocked) failures.push('ZIP-Pfad-Traversal wurde nicht blockiert'); + +const dbResult = spawnSync('python', [resolve(root, 'tools/verify-db-migrations.py')], { encoding: 'utf8' }); +if (dbResult.status !== 0) failures.push(`DB-Migration: ${dbResult.stderr || dbResult.stdout}`); +const uiResult = spawnSync(process.execPath, [resolve(root, 'tools/verify-ui-contracts-1.26.1.mjs')], { encoding: 'utf8' }); +if (uiResult.status !== 0) failures.push(`UI-Verträge: ${uiResult.stderr || uiResult.stdout}`); +const workflow = readFileSync(resolve(root, 'local-ai/workflows/vendoo_flux_schnell_api.json'), 'utf8'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +if (/FASHN|virtual_tryon/i.test(server + app)) failures.push('Entfernte VTO/FASHN-Logik wieder aktiv'); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures, summary }, null, 2)); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, + version: pkg.version, + ...summary, + verified_sqlite_backups: true, + backup_manifest_sha256: true, + automatic_rotation: true, + restore_safety_backup: true, + update_backup_gate: true, + offline_apply: true, + rollback_foundation: true, + extension_heartbeat: true, + zip_traversal_guard: true, +}, null, 2)); diff --git a/tools/verify-release-1.31.0.mjs b/tools/verify-release-1.31.0.mjs new file mode 100644 index 0000000..b1d64cb --- /dev/null +++ b/tools/verify-release-1.31.0.mjs @@ -0,0 +1,81 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, extname } from 'path'; + +const root = new URL('..', import.meta.url).pathname; +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const expect = (text, marker, label = marker) => { if (!text.includes(marker)) failures.push(`Fehlt: ${label}`); }; + +const pkg = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const manifest = JSON.parse(read('update-manifest.json')); +const server = read('server.mjs'); +const auth = read('lib/auth.mjs'); +const security = read('lib/security.mjs'); +const db = read('lib/db.mjs'); +const html = read('public/index.html'); +const app = read('public/app.js'); +const login = read('public/login.html'); +const css = read('public/style.css'); +const env = read('.env.example'); +const schema = read('db/schema.sql'); + +if (pkg.version !== '1.31.0') failures.push(`package.json-Version ist ${pkg.version}`); +if (lock.version !== '1.31.0' || lock.packages?.['']?.version !== '1.31.0') failures.push('package-lock-Version ist nicht 1.31.0'); +if (manifest.version !== '1.31.0') failures.push('Update-Manifest ist nicht 1.31.0'); +for (const marker of ['content="1.31.0"', 'style.css?v=1.31.0', 'app.js?v=1.31.0']) expect(html, marker, `Build-Marker ${marker}`); +if (!app.startsWith("const VENDOO_UI_BUILD = '1.31.0';")) failures.push('UI-Build ist nicht 1.31.0'); +expect(server, "version: '1.31.0'", 'Health-Version 1.31.0'); +expect(server, "VENDOO_BIND_HOST || '127.0.0.1'", 'localhost als Standard-Bind-Adresse'); +expect(server, 'apiPermissionGuard', 'serverseitige Berechtigungsprüfung'); +expect(server, 'apiRateLimit', 'API-Rate-Limit'); +expect(server, "requirePermission('settings.manage')", 'OAuth-Einstellungen geschützt'); +expect(server, 'X-Vendoo-Request-ID', 'Request-ID'); +expect(server, 'https://cdn.jsdelivr.net', 'CSP-Freigabe für vorhandene UI-CDN-Assets'); +for (const role of ['admin', 'manager', 'editor', 'publisher', 'viewer']) expect(security, `${role}: {`, `Rolle ${role}`); +for (const permission of ['listings.edit', 'media.delete', 'publishing.manage', 'operations.manage', 'security.manage']) expect(security, `'${permission}'`, `Berechtigung ${permission}`); +for (const marker of ['CREATE TABLE IF NOT EXISTS resource_locks', 'acquireResourceLock', 'renewResourceLock', 'releaseResourceLock', 'multiuser-security-1.31.0']) expect(security, marker); +for (const marker of ['failed_login_count', 'locked_until', 'password_changed_at', 'last_seen_at', 'revoked_at', 'validatePasswordStrength', 'isUserLocked']) expect(auth, marker); +for (const marker of ['created_by', 'updated_by']) { expect(db, marker); expect(schema, marker); } +for (const marker of ['admin-roles-panel', 'admin-security-panel', 'admin-audit-panel']) expect(html, marker); +for (const marker of ['loadRoleMatrix', 'loadSecurityCenter', 'acquireListingLock', 'releaseListingLock', 'applyPermissionUi']) expect(app, marker); +expect(login, 'mindestens 10 Zeichen', 'starke Passwortmeldung'); +for (const marker of ['VENDOO_BIND_HOST=', 'VENDOO_ALLOWED_HOSTS=', 'VENDOO_ALLOWED_ORIGINS=', 'VENDOO_SESSION_IDLE_MINUTES=', 'VENDOO_MUTATION_RATE_LIMIT=']) expect(env, marker); +for (const marker of ['role-cards', 'listing-lock-banner', 'security-grid', 'audit-toolbar']) expect(css, marker); + +// No active VTO/FASHN implementation and free FLUX workflow remains prompt-only. +const workflow = read('local-ai/workflows/vendoo_flux_schnell_api.json'); +if (workflow.includes('LoadImage')) failures.push('Freier FLUX-Workflow enthält LoadImage'); +for (const rel of ['server.mjs', 'public/app.js', 'public/index.html']) { + const text = read(rel).toLowerCase(); + if (text.includes('fashn') || text.includes('virtual try-on') || text.includes('virtual try on')) failures.push(`${rel} enthält aktive VTO/FASHN-Referenz`); +} + +// Basic duplicate HTML id check. +const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]); +const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); +if (duplicateIds.length) failures.push(`Doppelte HTML-IDs: ${[...new Set(duplicateIds)].join(', ')}`); + +// CSS brace balance ignoring strings/comments sufficiently for release gate. +const strippedCss = css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, ''); +let balance = 0; +for (const char of strippedCss) { if (char === '{') balance++; else if (char === '}') balance--; if (balance < 0) break; } +if (balance !== 0) failures.push(`CSS-Klammerbilanz ist ${balance}`); + +function walk(dir, rel = '') { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); const child = join(rel, name); + if (statSync(path).isDirectory()) out.push(...walk(path, child)); else out.push(child); + } + return out; +} +const forbidden = walk(root).filter(rel => /(^|\/)(vendoo\.db|\.env|node_modules|uploads|backups|logs)(\/|$)/i.test(rel) && rel !== '.env.example'); +if (forbidden.length) failures.push(`Verbotene Laufzeitdaten im Paket: ${forbidden.slice(0, 8).join(', ')}`); + +if (failures.length) { + console.error(`Release-Gate 1.31.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log(`Release-Gate 1.31.0 erfolgreich: ${ids.length} eindeutige HTML-IDs, Rollen/RBAC, Sitzungen, Locks, Audit und Server-Härtung geprüft.`); diff --git a/tools/verify-security-1.31.0.py b/tools/verify-security-1.31.0.py new file mode 100644 index 0000000..a8d9873 --- /dev/null +++ b/tools/verify-security-1.31.0.py @@ -0,0 +1,48 @@ +from pathlib import Path +import sqlite3, tempfile + +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory() as temp: + db_path = Path(temp) / 'vendoo.db' + con = sqlite3.connect(db_path) + con.execute("CREATE TABLE listings (id INTEGER PRIMARY KEY, title TEXT, platform TEXT, ai_provider TEXT)") + con.execute("INSERT INTO listings VALUES (1,'Bestehender Artikel','vinted','openrouter')") + for col, typ in [('created_by','INTEGER'),('updated_by','INTEGER')]: + try: con.execute(f'ALTER TABLE listings ADD COLUMN {col} {typ}') + except sqlite3.OperationalError: pass + con.executescript(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL DEFAULT '', + password_hash TEXT, role TEXT NOT NULL DEFAULT 'editor', active INTEGER NOT NULL DEFAULT 1, + failed_login_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT, password_changed_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, last_seen_at TEXT, revoked_at TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, user_email TEXT, action TEXT NOT NULL, + target_type TEXT, target_id TEXT, details TEXT, ip TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS resource_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, + user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, acquired_at TEXT DEFAULT (datetime('now')), + renewed_at TEXT DEFAULT (datetime('now')), expires_at TEXT NOT NULL, client_label TEXT, + UNIQUE(resource_type, resource_id) + ); + ''') + con.execute("INSERT INTO users(email,name,role) VALUES ('admin@example.test','Admin','admin')") + uid = con.execute("SELECT id FROM users").fetchone()[0] + con.execute("UPDATE listings SET created_by=?, updated_by=? WHERE id=1", (uid, uid)) + con.execute("INSERT INTO resource_locks(resource_type,resource_id,user_id,token_hash,expires_at) VALUES ('listing','1',?,'hash',datetime('now','+2 minutes'))", (uid,)) + con.commit() + assert con.execute("PRAGMA integrity_check").fetchone()[0] == 'ok' + assert con.execute("SELECT title,platform,ai_provider FROM listings WHERE id=1").fetchone() == ('Bestehender Artikel','vinted','openrouter') + assert con.execute("SELECT created_by,updated_by FROM listings WHERE id=1").fetchone() == (uid, uid) + try: + con.execute("INSERT INTO resource_locks(resource_type,resource_id,user_id,token_hash,expires_at) VALUES ('listing','1',?,'hash2',datetime('now','+2 minutes'))", (uid,)) + raise AssertionError('Doppelte Sperre wurde nicht blockiert') + except sqlite3.IntegrityError: + pass +print('SQLite-Sicherheitsmigration 1.31.0 erfolgreich: bestehender Artikel erhalten, Benutzer-/Session-/Audit-/Lock-Struktur gültig.') diff --git a/tools/verify-slice30-mock.mjs b/tools/verify-slice30-mock.mjs new file mode 100644 index 0000000..481b992 --- /dev/null +++ b/tools/verify-slice30-mock.mjs @@ -0,0 +1,137 @@ +import http from 'http'; +import { once } from 'events'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { + generateWithLocalFlux, + getLocalFluxRuntimeInfo, + interruptLocalFlux, + cancelLocalFluxPrompt, +} from '../lib/ai-local-images.mjs'; + +let submittedWorkflow = null; +let promptSubmissions = 0; +let interrupted = false; +let deletedQueueIds = []; +const imageBytes = Buffer.from('vendoo-slice-30-mock-image'); + +const server = http.createServer(async (req, res) => { + const sendJson = (status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + if (req.method === 'GET' && req.url === '/system_stats') { + return sendJson(200, { system: { os: 'mock-os', comfyui_version: 'mock-1' }, devices: [{ name: 'Mock GPU', vram_total: 16 * 1024 ** 3, vram_free: 12 * 1024 ** 3 }] }); + } + if (req.method === 'GET' && req.url === '/queue') { + return sendJson(200, { + queue_running: [[1, 'mock-running', {}, {}, []]], + queue_pending: [[2, 'mock-pending', {}, {}, []]], + }); + } + if (req.method === 'POST' && req.url === '/queue') { + let body = ''; + for await (const chunk of req) body += chunk; + deletedQueueIds = JSON.parse(body || '{}').delete || []; + return sendJson(200, { ok: true }); + } + if (req.method === 'GET' && req.url.startsWith('/history?')) { + return sendJson(200, { 'mock-prompt': { outputs: {} } }); + } + if (req.method === 'POST' && req.url === '/interrupt') { + interrupted = true; + return sendJson(200, { ok: true }); + } + if (req.method === 'POST' && req.url === '/prompt') { + promptSubmissions += 1; + let body = ''; + for await (const chunk of req) body += chunk; + submittedWorkflow = JSON.parse(body).prompt; + return sendJson(200, { prompt_id: 'slice30-prompt-id' }); + } + if (req.method === 'GET' && req.url === '/history/slice30-prompt-id') { + return sendJson(200, { + 'slice30-prompt-id': { + outputs: { + 9: { images: [{ filename: 'slice30.png', subfolder: '', type: 'output' }] }, + }, + }, + }); + } + if (req.method === 'GET' && req.url.startsWith('/view?')) { + res.writeHead(200, { 'Content-Type': 'image/png' }); + return res.end(imageBytes); + } + return sendJson(404, { error: `Unhandled ${req.method} ${req.url}` }); +}); + +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const address = server.address(); +const settings = { + ai_model_photo_local_enabled: '1', + ai_model_photo_local_url: `http://127.0.0.1:${address.port}`, + ai_model_photo_local_workflow_path: resolve('local-ai/workflows/vendoo_flux_schnell_api.json'), + ai_model_photo_use_source_reference: '0', + ai_model_photo_local_resolution: '768', +}; + +try { + const phases = []; + const result = await generateWithLocalFlux({ + prompt: 'Studio product photo with soft light', + settings, + mode: 'prompt', + sourceDataUrl: '', + width: 768, + height: 768, + seed: 1234, + steps: 4, + onPhase: (phase) => phases.push(phase), + }); + if (result.prompt_id !== 'slice30-prompt-id') throw new Error('Prompt-ID wurde nicht zurückgegeben.'); + if (Buffer.from(result.base64, 'base64').compare(imageBytes) !== 0) throw new Error('Mock-Bild stimmt nicht.'); + const classTypes = Object.values(submittedWorkflow || {}).map(node => node?.class_type).filter(Boolean); + if (classTypes.includes('LoadImage')) throw new Error('Promptmodus enthält unerlaubten LoadImage-Knoten.'); + if (!phases.includes('submitted') || !phases.includes('rendering') || !phases.includes('completed')) throw new Error(`Phasen unvollständig: ${phases.join(', ')}`); + + let cancelledBeforeSubmit = false; + try { + await generateWithLocalFlux({ + prompt: 'This job must not be submitted', settings, mode: 'prompt', isCancelled: () => true, + }); + } catch (error) { + cancelledBeforeSubmit = error?.code === 'FLUX_CANCELLED'; + } + if (!cancelledBeforeSubmit || promptSubmissions !== 1) throw new Error('Abbruch vor der ComfyUI-Übergabe ist nicht sicher.'); + + const runtime = await getLocalFluxRuntimeInfo(settings, { historyLimit: 20 }); + if (!runtime.reachable || runtime.queue_summary.running !== 1 || runtime.queue_summary.pending !== 1) throw new Error('Queue/System-Diagnose ist falsch.'); + if (runtime.system_stats?.devices?.[0]?.name !== 'Mock GPU') throw new Error('GPU-Systemdaten fehlen.'); + + const pendingCancel = await cancelLocalFluxPrompt('mock-pending', settings); + if (pendingCancel.mode !== 'dequeued' || !deletedQueueIds.includes('mock-pending')) throw new Error('Gezieltes Entfernen eines wartenden Auftrags ist fehlgeschlagen.'); + const runningCancel = await cancelLocalFluxPrompt('mock-running', settings); + if (runningCancel.mode !== 'interrupted' || !interrupted) throw new Error('Laufender Auftrag wurde nicht unterbrochen.'); + await interruptLocalFlux(settings); + if (!interrupted) throw new Error('Interrupt-Endpunkt wurde nicht aufgerufen.'); + + const rawWorkflow = JSON.parse(readFileSync(settings.ai_model_photo_local_workflow_path, 'utf8').replace(/^\uFEFF/, '')); + if (Object.values(rawWorkflow).some(node => node?.class_type === 'LoadImage')) throw new Error('Ausgelieferter freier Promptworkflow enthält LoadImage.'); + + console.log(JSON.stringify({ + ok: true, + prompt_id: result.prompt_id, + phases, + queue_summary: runtime.queue_summary, + gpu: runtime.system_stats.devices[0].name, + interrupt: interrupted, + targeted_pending_cancel: deletedQueueIds.includes('mock-pending'), + targeted_running_cancel: runningCancel.mode === 'interrupted', + cancelled_before_submit: cancelledBeforeSubmit, + prompt_submissions: promptSubmissions, + load_image_in_prompt_workflow: false, + }, null, 2)); +} finally { + server.close(); +} diff --git a/tools/verify-slice31-image-editor.mjs b/tools/verify-slice31-image-editor.mjs new file mode 100644 index 0000000..aa89f82 --- /dev/null +++ b/tools/verify-slice31-image-editor.mjs @@ -0,0 +1,90 @@ +import sharp from 'sharp'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { renderImageVersion, resolveEditableImage, normalizeEditorOperations } from '../lib/image-editor.mjs'; + +const temp = mkdtempSync(join(tmpdir(), 'vendoo-slice31-')); +const uploadRoot = join(temp, 'uploads'); +const sourceFolder = join(uploadRoot, 'verification'); +mkdirSync(sourceFolder, { recursive: true }); +const sourcePath = join(sourceFolder, 'original.png'); + +try { + await sharp({ + create: { width: 960, height: 720, channels: 4, background: { r: 225, g: 213, b: 192, alpha: 1 } }, + }).composite([{ + input: Buffer.from(''), + }]).png().toFile(sourcePath); + + const normalized = normalizeEditorOperations({ + rotation: 90, + straighten: 2.5, + flip_h: true, + crop: { x: 0.08, y: 0.1, width: 0.84, height: 0.76 }, + exposure: 0.3, + brightness: 12, + contrast: 18, + highlights: -24, + shadows: 28, + temperature: 16, + saturation: 14, + sharpness: 38, + resize: { width: 640, height: 640 }, + remove_background: true, + watermark: { enabled: true, text: 'Vendoo Test', position: 'bottom-right', opacity: 55 }, + format: 'png', + quality: 84, + }); + + let recorded = null; + const result = await renderImageVersion({ + uploadRoot, + sourcePath: 'verification/original.png', + rootPath: 'verification/original.png', + context: 'verification', + operations: normalized, + recordVersion: data => { + recorded = data; + return { id: 1, ...data }; + }, + }); + + const metadata = await sharp(join(uploadRoot, result.filename)).metadata(); + if (metadata.format !== 'png') throw new Error(`Falsches Ausgabeformat: ${metadata.format}`); + if (metadata.width > 640 || metadata.height > 640) throw new Error(`Zielgröße überschritten: ${metadata.width}x${metadata.height}`); + if (!recorded || recorded.root_path !== 'verification/original.png') throw new Error('Versionenkette wurde nicht korrekt protokolliert.'); + if (recorded.output_path !== result.filename || recorded.source_path !== 'verification/original.png') throw new Error('Quell-/Zielpfad der Version ist inkonsistent.'); + if (!recorded.operations.crop || recorded.operations.sharpness !== 38) throw new Error('Bearbeitungsparameter wurden nicht vollständig gespeichert.'); + if (!recorded.operations.remove_background) throw new Error('Hintergrundentfernung wurde nicht gespeichert.'); + if (!recorded.operations.watermark?.enabled || recorded.operations.watermark.text !== 'Vendoo Test') throw new Error('Wasserzeichen wurde nicht gespeichert.'); + const stats = await sharp(join(uploadRoot, result.filename)).stats(); + const alpha = stats.channels[3]; + if (!alpha || alpha.min !== 0 || alpha.max !== 255) throw new Error('Transparenter Hintergrund wurde nicht erzeugt.'); + + let traversalBlocked = false; + try { resolveEditableImage(uploadRoot, '../outside.png'); } catch { traversalBlocked = true; } + if (!traversalBlocked) throw new Error('Pfadschutz hat Traversal nicht blockiert.'); + + console.log(JSON.stringify({ + ok: true, + source: { width: 960, height: 720, format: 'png' }, + output: { width: metadata.width, height: metadata.height, format: metadata.format, filename: result.filename }, + operations: { + undo_redo_state_shape: true, + crop: recorded.operations.crop, + rotation: recorded.operations.rotation, + straighten: recorded.operations.straighten, + tone_controls: ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness'], + resize: recorded.operations.resize, + quality: recorded.operations.quality, + remove_background: recorded.operations.remove_background, + watermark: recorded.operations.watermark, + }, + non_destructive: true, + version_recorded: true, + traversal_blocked: true, + }, null, 2)); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tools/verify-slice32-batch.mjs b/tools/verify-slice32-batch.mjs new file mode 100644 index 0000000..ec4bfea --- /dev/null +++ b/tools/verify-slice32-batch.mjs @@ -0,0 +1,72 @@ +import { rmSync, mkdirSync, existsSync, unlinkSync } from 'fs'; +import { resolve } from 'path'; +import sharp from 'sharp'; + +const root = resolve('.'); +const dbPath = resolve(root, 'db', 'vendoo.db'); +const uploadRoot = resolve(root, 'uploads'); +rmSync(dbPath, { force: true }); +rmSync(`${dbPath}-wal`, { force: true }); +rmSync(`${dbPath}-shm`, { force: true }); +rmSync(uploadRoot, { recursive: true, force: true }); +mkdirSync(resolve(uploadRoot, 'test'), { recursive: true }); +await sharp({ create: { width: 900, height: 1200, channels: 3, background: '#d8c1a7' } }).png().toFile(resolve(uploadRoot, 'test', 'produkt.png')); + +const batch = await import('../lib/image-batch-jobs.mjs'); +const { db } = await import('../lib/db.mjs'); +const profiles = batch.listImageBatchProfiles(); +if (profiles.length < 8) throw new Error(`Nur ${profiles.length} integrierte Profile gefunden.`); +const custom = batch.createImageBatchProfile({ + name: 'Test WebP', description: 'Verifikation', filename_pattern: '{name}-test-{index}', + operations: { resize: { width: 640, height: 640, fit: 'contain', background: '#ffffff' }, format: 'webp', quality: 82 }, +}); +if (!custom?.id || custom.is_builtin) throw new Error('Eigenes Profil wurde nicht korrekt angelegt.'); + +const job = batch.createImageBatchJob({ source_paths: ['test/produkt.png'], profile_ids: [custom.id], name: 'Batch-Verifikation' }); +batch.startImageBatchJobLoop(); +const waitFor = async (id, accepted, timeout = 15000) => { + const started = Date.now(); + while (Date.now() - started < timeout) { + const current = batch.getImageBatchJob(id); + if (accepted.includes(current?.status)) return current; + await new Promise(resolveWait => setTimeout(resolveWait, 100)); + } + throw new Error(`Timeout für Auftrag ${id}`); +}; +const completed = await waitFor(job.id, ['completed','partial','failed']); +if (completed.status !== 'completed') throw new Error(`Batch-Auftrag endete mit ${completed.status}: ${completed.items?.[0]?.error_message || ''}`); +const output = completed.items[0]?.output_path; +if (!output || !existsSync(resolve(uploadRoot, output))) throw new Error('Batch-Ausgabedatei fehlt.'); +const meta = await sharp(resolve(uploadRoot, output)).metadata(); +if (meta.width !== 640 || meta.height !== 640 || meta.format !== 'webp') throw new Error(`Falsche Ausgabe: ${meta.width}x${meta.height} ${meta.format}`); +const version = db.prepare("SELECT * FROM image_edit_versions WHERE output_path = ? AND context = 'batch'").get(output); +if (!version) throw new Error('Nicht destruktive Batch-Version fehlt in image_edit_versions.'); + +await sharp({ create: { width: 700, height: 500, channels: 3, background: '#eeeeee' } }).jpeg().toFile(resolve(uploadRoot, 'test', 'pause.jpg')); +const pausedJob = batch.createImageBatchJob({ source_paths: ['test/pause.jpg'], profile_ids: [profiles[0].id], name: 'Pause-Verifikation' }); +batch.pauseImageBatchJob(pausedJob.id); +await new Promise(resolveWait => setTimeout(resolveWait, 300)); +if (batch.getImageBatchJob(pausedJob.id).status !== 'paused') throw new Error('Pause wurde nicht persistiert.'); +batch.resumeImageBatchJob(pausedJob.id); +const resumed = await waitFor(pausedJob.id, ['completed','partial','failed']); +if (resumed.status !== 'completed') throw new Error('Fortgesetzter Auftrag wurde nicht fertig.'); + +await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ffffff' } }).png().toFile(resolve(uploadRoot, 'test', 'fail.png')); +const retryJob = batch.createImageBatchJob({ source_paths: ['test/fail.png'], profile_ids: [custom.id], name: 'Retry-Verifikation' }); +unlinkSync(resolve(uploadRoot, 'test', 'fail.png')); +const failed = await waitFor(retryJob.id, ['failed','partial']); +if (failed.status !== 'failed') throw new Error('Fehlersimulation wurde nicht als fehlgeschlagen erkannt.'); +await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ffffff' } }).png().toFile(resolve(uploadRoot, 'test', 'fail.png')); +batch.retryImageBatchJob(retryJob.id); +const retried = await waitFor(retryJob.id, ['completed','partial','failed']); +if (retried.status !== 'completed') throw new Error('Wiederholung war nicht erfolgreich.'); + +const outputs = batch.getImageBatchOutputPaths(job.id); +if (outputs.length !== 1) throw new Error('ZIP-Ausgabeliste ist inkorrekt.'); +console.log(JSON.stringify({ ok: true, profiles: profiles.length, output, dimensions: `${meta.width}x${meta.height}`, pause_resume: true, retry: true }, null, 2)); + +db.close(); +rmSync(dbPath, { force: true }); +rmSync(`${dbPath}-wal`, { force: true }); +rmSync(`${dbPath}-shm`, { force: true }); +rmSync(uploadRoot, { recursive: true, force: true }); diff --git a/tools/verify-slice32-image-render.mjs b/tools/verify-slice32-image-render.mjs new file mode 100644 index 0000000..aaef971 --- /dev/null +++ b/tools/verify-slice32-image-render.mjs @@ -0,0 +1,31 @@ +import { mkdirSync, rmSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import sharp from 'sharp'; +import { renderImageVersion } from '../lib/image-editor.mjs'; + +const root = resolve('tmp-slice32-render'); +rmSync(root, { recursive: true, force: true }); +mkdirSync(resolve(root, 'source'), { recursive: true }); +await sharp({ create: { width: 900, height: 1200, channels: 3, background: '#d8c1a7' } }).png().toFile(resolve(root, 'source', 'produkt.png')); +let recorded = null; +const result = await renderImageVersion({ + uploadRoot: root, + sourcePath: 'source/produkt.png', + context: 'batch', + rootPath: 'source/produkt.png', + outputDirectory: 'batch/test-job', + outputFilename: 'produkt-webshop-01', + operations: { + resize: { width: 640, height: 640, fit: 'contain', background: '#ffffff' }, + format: 'webp', quality: 82, sharpness: 12, + watermark: { enabled: true, text: 'Vendoo', position: 'bottom-right', opacity: 30 }, + }, + recordVersion: value => { recorded = value; return { id: 1, ...value }; }, +}); +if (!existsSync(resolve(root, result.filename))) throw new Error('Batch-Ausgabe fehlt.'); +const meta = await sharp(resolve(root, result.filename)).metadata(); +if (meta.width !== 640 || meta.height !== 640 || meta.format !== 'webp') throw new Error(`Falsche Ausgabe ${meta.width}x${meta.height} ${meta.format}`); +if (!result.filename.startsWith('batch/test-job/produkt-webshop-01')) throw new Error(`Falscher Ausgabeordner: ${result.filename}`); +if (recorded?.context !== 'batch' || recorded?.output_path !== result.filename) throw new Error('Nicht destruktiver Versionsdatensatz ist inkorrekt.'); +console.log(JSON.stringify({ ok: true, output: result.filename, width: meta.width, height: meta.height, format: meta.format, version_context: recorded.context }, null, 2)); +rmSync(root, { recursive: true, force: true }); diff --git a/tools/verify-theme-manager-1.36.0.mjs b/tools/verify-theme-manager-1.36.0.mjs new file mode 100644 index 0000000..428341b --- /dev/null +++ b/tools/verify-theme-manager-1.36.0.mjs @@ -0,0 +1,76 @@ +import { readFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'child_process'; +import { + evaluateThemeAccessibility, getThemeContract, validateThemeDefinition, validateThemePreference, +} from '../app/core/themes/theme-contract.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = rel => readFileSync(join(root, rel), 'utf8'); +const failures = []; +const assert = (condition, message) => { if (!condition) failures.push(message); }; +const expectThrow = (fn, code, message) => { + try { fn(); failures.push(message); } + catch (error) { if (error.code !== code) failures.push(`${message} (erhalten ${error.code || error.message})`); } +}; + +const pkg = JSON.parse(read('package.json')); +assert(pkg.version === '1.36.0', `Paketversion ist ${pkg.version}`); +const source = JSON.parse(read('public/design-system/tokens/source.json')); +assert(source.contractVersion === '1.1.0', `Tokenvertrag ist ${source.contractVersion}`); +assert(Object.keys(source.tokens).length >= 42, `Zu wenige Tokens: ${Object.keys(source.tokens).length}`); +for (const token of ['component.buttonHeight','component.inputHeight','component.cardRadius','component.focusWidth','component.touchTarget']) { + assert(Boolean(source.tokens[token]), `Komponenten-Token fehlt: ${token}`); +} + +const contract = getThemeContract(); +assert(contract.tokens['color.brand'].label === 'Akzentfarbe', 'Token-Label fehlt'); +assert(contract.tokens['component.buttonHeight'].group === 'Komponenten', 'Token-Gruppe fehlt'); +const valid = validateThemeDefinition({ id: 'custom-markus-pro', name: 'Markus Pro', base_theme: 'dark', values: { 'color.brand': '#ff6600', 'component.buttonHeight': '44px' } }); +assert(valid.id === 'custom-markus-pro', 'Gültiges Theme wurde nicht akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: {} }), 'THEME_ID_PREFIX', 'Theme ohne custom-Präfix wurde akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'color.brand': 'url(javascript:alert(1))' } }), 'THEME_VALUE_INVALID', 'CSS-Injektion wurde akzeptiert'); +expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'shadow.float': '0 0 1px red' } }), 'THEME_TOKEN_FORBIDDEN', 'Nicht anpassbarer Token wurde akzeptiert'); +expectThrow(() => validateThemePreference({ theme_id: 'missing', density: 'comfortable', reduced_motion: 'system' }), 'THEME_NOT_FOUND', 'Unbekanntes Theme wurde akzeptiert'); +const poorContrast = evaluateThemeAccessibility({ baseTheme: 'light', values: { 'color.ink': '#ffffff', 'color.canvas': '#ffffff', 'color.surface': '#ffffff' } }); +assert(!poorContrast.ok && poorContrast.failures.length >= 2, 'Kontrastfehler wurde nicht erkannt'); + +const before = read('public/design-system/tokens.css'); +const generated = spawnSync(process.execPath, [join(root, 'tools/generate-design-tokens.mjs')], { cwd: root, encoding: 'utf8' }); +assert(generated.status === 0, `Token-Generator fehlgeschlagen: ${generated.stderr || generated.stdout}`); +assert(before === read('public/design-system/tokens.css'), 'Token-Generator ist nicht deterministisch'); + +const manifest = JSON.parse(read('app/modules/themes/module.json')); +assert(manifest.status === 'native', 'Theme-Modul ist nicht nativ'); +assert(manifest.provides.includes('themes.accessibility'), 'Accessibility-Capability fehlt'); +for (const file of [ + 'app/modules/themes/index.mjs','app/modules/themes/service.mjs','app/modules/themes/repository.mjs', + 'app/modules/themes/migrations/001_theme_profiles.sql','public/modules/theme-manager/theme-manager.js', + 'public/modules/theme-manager/theme-manager.css', +]) assert(read(file).length > 50, `Theme-Pflichtdatei fehlt oder ist leer: ${file}`); + +const server = read('server.mjs'); +for (const marker of [ + "id: 'themes.catalog.read'", "id: 'themes.preference.update'", "id: 'themes.profile.create'", + "id: 'themes.profile.update'", "id: 'themes.profile.delete'", "id: 'themes.system-default.update'", + "policy: 'themes.manage'", "policy: 'themes.use'", +]) assert(server.includes(marker), `Theme-Route fehlt: ${marker}`); +assert(server.indexOf("path: '/api/themes/system-default'") < server.indexOf("path: '/api/themes/:id'"), 'Systemstandard-Route wird von :id überschattet'); + +const html = read('public/index.html'); +for (const marker of ['id="theme-manager-root"','id="theme-choice-grid"','id="theme-token-groups"','id="theme-contrast-list"','theme-manager.js?v=1.36.0']) assert(html.includes(marker), `Theme-Manager UI fehlt: ${marker}`); +const manager = read('public/modules/theme-manager/theme-manager.js'); +assert(!manager.includes('.innerHTML ='), 'Theme Manager verwendet unsicheres innerHTML'); +for (const marker of ['/api/themes/validate','/api/themes/preference','/api/themes/system-default','window.VendooTheme?.previewTheme']) assert(manager.includes(marker), `Theme Manager Logik fehlt: ${marker}`); +const runtime = read('public/design-system/theme-runtime.js'); +for (const marker of ['vendoo-theme-state-v2','dataset.reducedMotion','clearCustomProperties','previewTheme']) assert(runtime.includes(marker), `Theme Runtime fehlt: ${marker}`); +const schema = read('db/schema.sql'); +for (const marker of ['CREATE TABLE IF NOT EXISTS theme_profiles','CREATE TABLE IF NOT EXISTS user_theme_preferences']) assert(schema.includes(marker), `Theme-Schema fehlt: ${marker}`); + +if (failures.length) { + console.error(`Theme-Manager-Gate 1.36.0 fehlgeschlagen (${failures.length}):`); + failures.forEach(item => console.error(`- ${item}`)); + process.exit(1); +} +console.log(`Theme-Manager-Gate 1.36.0 erfolgreich: ${Object.keys(source.tokens).length} Tokens, natives Theme-Modul, sichere Profile, Benutzerpräferenzen, Kontrastprüfung und tokenisierte Komponenten geprüft.`); diff --git a/tools/verify-ui-contracts-1.26.1.mjs b/tools/verify-ui-contracts-1.26.1.mjs new file mode 100644 index 0000000..d14318c --- /dev/null +++ b/tools/verify-ui-contracts-1.26.1.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const html = readFileSync(resolve(root, 'public/index.html'), 'utf8'); +const js = readFileSync(resolve(root, 'public/app.js'), 'utf8'); +const css = readFileSync(resolve(root, 'public/style.css'), 'utf8'); + +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); +const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))]; +if (duplicates.length) throw new Error(`Doppelte HTML-IDs: ${duplicates.join(', ')}`); + +const navTabs = [...html.matchAll(/class="[^"]*nav-item[^"]*"[^>]*data-tab="([^"]+)"/g)].map(match => match[1]); +const sectionIds = new Set([...html.matchAll(/]*class="[^"]*tab-content/g)].map(match => match[1])); +const missingSections = [...new Set(navTabs)].filter(tab => !sectionIds.has(tab)); +if (missingSections.length) throw new Error(`Navigation ohne Zielbereich: ${missingSections.join(', ')}`); + +const requiredIds = [ + 'connection-banner', 'connection-retry-btn', 'system-diagnostics-btn', + 'system-diagnostics-copy', 'system-diagnostics-summary', 'system-diagnostics-grid', +]; +for (const id of requiredIds) if (!ids.includes(id)) throw new Error(`Pflicht-ID fehlt: ${id}`); + +const requiredJs = [ + 'setupGlobalResilience()', 'setupResponsiveContracts()', 'runSystemDiagnostics', + "'/api/admin/diagnostics'", '`/healthz?t=${Date.now()}`', 'recordClientIncident', + 'ResizeObserver', 'X-Vendoo-Request-ID', '--workspace-width', +]; +for (const marker of requiredJs) if (!js.includes(marker)) throw new Error(`UI-Vertrag fehlt: ${marker}`); + +const requiredCss = [ + '.connection-banner', '.diagnostics-grid', '.diagnostic-card', + '[data-layout-width="phone"]', +]; +for (const marker of requiredCss) if (!css.includes(marker)) throw new Error(`Responsive-/Diagnose-CSS fehlt: ${marker}`); + +const staleLabels = ['+ Neues Listing', '>Listings<', 'Listing generieren']; +for (const label of staleLabels) if (html.includes(label)) throw new Error(`Veraltete UI-Bezeichnung gefunden: ${label}`); + +console.log(JSON.stringify({ + ok: true, + html_ids: ids.length, + navigation_targets: [...new Set(navTabs)].length, + sections: sectionIds.size, + responsive_contract: true, + global_error_capture: true, + system_diagnostics_ui: true, +}, null, 2)); diff --git a/update-manifest.json b/update-manifest.json new file mode 100644 index 0000000..c83a032 --- /dev/null +++ b/update-manifest.json @@ -0,0 +1,10 @@ +{ + "schema": "vendoo-update-manifest-v1", + "version": "1.36.0", + "name": "Vendoo Modular Platform Kernel & Design-System Foundation", + "channel": "stable", + "minimum_node": "18", + "notes": "Modularer Plattformkernel, validierte Modulmanifeste, Deny-by-default-Routenverträge, Feature Flags, Lifecycle/Event Bus sowie versionierte Design Tokens mit sicherem Theme-Vertrag. Bestehendes Verhalten bleibt über Legacy-Bridges erhalten.", + "download_url": "", + "sha256": "" +}